icebear0828 Claude Opus 4.6 commited on
Commit
2996fd8
·
1 Parent(s): 60509b4

feat: display secondary rate limit window (weekly cap) in dashboard

Browse files

Add secondary_rate_limit to CodexQuota type and map secondary_window
from Codex usage API. Dashboard now shows both primary (hourly) and
secondary (weekly) quota bars with independent progress and reset times.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

README.md CHANGED
@@ -36,62 +36,124 @@
36
 
37
  ## 🚀 快速开始 (Quick Start)
38
 
39
- ### 桌面应用最简单
40
 
41
- 从 [GitHub Releases](https://github.com/icebear0828/codex-proxy/releases) 下载安装包,开箱即用:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
 
43
- | 平台 | 安装包 |
44
- |------|--------|
45
- | Windows | `Codex Proxy Setup x.x.x.exe` |
46
- | macOS | `Codex Proxy-x.x.x.dmg` |
47
- | Linux | `Codex Proxy-x.x.x.AppImage` |
48
 
49
- 安装后打开应用使用 ChatGPT 账号登录即可。桌面端默认监听 `127.0.0.1:8080`,仅本机访问
50
 
51
- ### CLI / 服务器部署
 
 
 
 
 
 
 
 
 
 
 
 
52
 
53
  ```bash
54
  git clone https://github.com/icebear0828/codex-proxy.git
55
  cd codex-proxy
56
  ```
57
 
58
- ### Docker(推荐所有平台通用)
 
 
59
 
60
  ```bash
61
- cp .env.example .env # 创建环境变量文件(可编辑配置
62
- docker compose up -d
63
- # 打开 http://localhost:8080 登录
64
  ```
65
 
66
- 数据持久化通过 volume 映射`data/`(账号、Cookie)和 `config/`(配置文件)。
 
 
 
 
 
 
 
 
 
 
 
 
67
 
68
- > **跨容器访问提示**:如果其他 Docker 容器(如 OpenClaw、Cursor Server 等)需要连接 codex-proxy,建议使用宿主机的局域网 IP(如 `http://192.168.x.x:8080/v1`)而非 `host.docker.internal`,以避免 Docker DNS 解析问题
 
 
 
 
 
 
 
69
 
70
- ### macOS / Linux
71
 
72
  ```bash
73
- npm install # 安装后端依赖 + 自动下载 curl-impersonate
74
- cd web && npm install && cd .. # 安装前端依赖
75
- npm run dev # 开发模式(热重载)
76
- # 或:npm run build && npm start # 生产模式
77
  ```
78
 
79
- > 也支持 `pnpm` `bun`,将上方 `npm` 替换即可
 
 
 
 
 
 
 
80
 
81
- ### Windows
82
 
83
  ```bash
84
- npm install # 安装后端依赖
85
- cd web && npm install && cd .. # 安装前端依赖
86
- npm run dev # 开发模式(热重载)
87
  ```
88
 
89
- > Windows 下 curl-impersonate 暂不可用,自动降级为系统 curl(无 Chrome TLS 伪装)。建议搭配本地代理使用,或通过 Docker / WSL 部署以获得完整 TLS 伪装能力。
90
 
91
- ### 验证
 
 
 
 
 
 
92
 
93
  ```bash
94
- # 打开 http://localhost:8080 使用 ChatGPT 账号登录,然后:
95
  curl http://localhost:8080/v1/chat/completions \
96
  -H "Content-Type: application/json" \
97
  -d '{
@@ -101,6 +163,8 @@ curl http://localhost:8080/v1/chat/completions \
101
  }'
102
  ```
103
 
 
 
104
  ## 🌟 核心功能 (Features)
105
 
106
  ### 1. 🔌 全协议兼容 (Multi-Protocol API)
 
36
 
37
  ## 🚀 快速开始 (Quick Start)
38
 
39
+ > **前置条件**:你需要一个 ChatGPT 账号免费账号即可。如果还没有,先去 [chat.openai.com](https://chat.openai.com) 注册一个。
40
 
41
+ ---
42
+
43
+ ### 方式一:桌面应用(推荐新手,最简单)
44
+
45
+ 像安装 QQ、微信一样,下载 → 安装 → 打开就能用。
46
+
47
+ **第 1 步:下载安装包**
48
+
49
+ 打开 👉 [下载页面](https://github.com/icebear0828/codex-proxy/releases),找到最新版本,根据你的电脑系统下载对应文件:
50
+
51
+ | 你的电脑系统 | 下载哪个文件 | 怎么判断系统 |
52
+ |-------------|-------------|-------------|
53
+ | Windows | `Codex Proxy Setup x.x.x.exe` | 大部分人用的就是 Windows |
54
+ | macOS(苹果电脑) | `Codex Proxy-x.x.x.dmg` | 带苹果 logo 的笔记本/台式机 |
55
+ | Linux | `Codex Proxy-x.x.x.AppImage` | 你如果用 Linux 你肯定知道 |
56
+
57
+ **第 2 步:安装**
58
+
59
+ - **Windows**:双击下载的 `.exe` 文件,一路点"下一步"直到安装完成
60
+ - **macOS**:双击 `.dmg` 文件,把图标拖到 Applications 文件夹里
61
+
62
+ **第 3 步:打开并登录**
63
+
64
+ 1. 打开刚装好的「Codex Proxy」应用
65
+ 2. 点击页面上的登录按钮,用你的 ChatGPT 账号登录
66
+ 3. 登录成功后,代理服务就已经在运行了
67
 
68
+ **第 4 步:验证是否成功**
 
 
 
 
69
 
70
+ 打开浏览器在地址栏输入 `http://localhost:8080`,如果能看到控制面板页面,说明一切正常
71
 
72
+ > 桌面端默认只允许本机访问(`127.0.0.1:8080`),你电脑上的 AI 客户端可以直接连。
73
+
74
+ ---
75
+
76
+ ### 方式二:Docker 部署(推荐服务器 / 进阶用户)
77
+
78
+ Docker 就像一个"打包好的盒子",不需要你自己装各种依赖,一条命令就能跑起来。
79
+
80
+ **前置条件**:电脑上已安装 Docker。如果没有,先去 [docker.com](https://www.docker.com/products/docker-desktop/) 下载安装 Docker Desktop。
81
+
82
+ **第 1 步:下载项目代码**
83
+
84
+ 打开终端(Windows 搜索"cmd"或"PowerShell";macOS 搜索"终端"),输入:
85
 
86
  ```bash
87
  git clone https://github.com/icebear0828/codex-proxy.git
88
  cd codex-proxy
89
  ```
90
 
91
+ > 如果提示 `git` 不是命令需要先安装 Git:去 [git-scm.com](https://git-scm.com/) 下载安装。
92
+
93
+ **第 2 步:创建配置文件并启动**
94
 
95
  ```bash
96
+ cp .env.example .env # 复制一份配置模板
97
+ docker compose up -d # 启动服务(后台运行)
 
98
  ```
99
 
100
+ **第 3 登录**
101
+
102
+ 打开浏览器,访问 `http://localhost:8080`,用 ChatGPT 账号登录。
103
+
104
+ > 你的账号数据会保存在项目的 `data/` 文件夹里,重启不会丢失。
105
+ >
106
+ > **其他 Docker 容器要连本服务?** 用你电脑的局域网 IP(如 `http://192.168.x.x:8080/v1`),不要用 `localhost`。
107
+
108
+ ---
109
+
110
+ ### 方式三:源码运行(开发者 / 想改代码的用户)
111
+
112
+ 直接从源代码启动,适合想自己修改或调试的人。
113
 
114
+ **前置条件**:已安装 [Node.js](https://nodejs.org/) 18 或更高版本安装时一路默认选项即可。
115
+
116
+ **第 1 步:下载项目代码**
117
+
118
+ ```bash
119
+ git clone https://github.com/icebear0828/codex-proxy.git
120
+ cd codex-proxy
121
+ ```
122
 
123
+ **第 2 步:安装依赖**
124
 
125
  ```bash
126
+ npm install # 安装后端依赖
127
+ cd web && npm install && cd .. # 安装前端依赖
 
 
128
  ```
129
 
130
+ > macOS / Linux 会自动下载 curl-impersonate(用于 Chrome TLS 伪装)
131
+ > Windows 下不可用,会自动降级为系统 curl,建议搭配本地代理或改用 Docker。
132
+
133
+ **第 3 步:启动服务**
134
+
135
+ ```bash
136
+ npm run dev # 开发模式(改了代码自动重启,适合调试)
137
+ ```
138
 
139
+ 或者,如果你要正式使用:
140
 
141
  ```bash
142
+ npm run build # 先编译
143
+ npm start # 再启动(性能更好)
 
144
  ```
145
 
146
+ **第 4 步:登录**
147
 
148
+ 打开浏览器,访问 `http://localhost:8080`,用 ChatGPT 账号登录。
149
+
150
+ ---
151
+
152
+ ### ✅ 验证服务是否正常
153
+
154
+ 登录成功后,打开终端,复制粘贴以下命令并回车:
155
 
156
  ```bash
 
157
  curl http://localhost:8080/v1/chat/completions \
158
  -H "Content-Type: application/json" \
159
  -d '{
 
163
  }'
164
  ```
165
 
166
+ 如果看到一串 AI 回复的文字流出来,恭喜你,部署成功!接下来可以去 [客户端接入](#-客户端接入-client-setup) 章节,把它连到你常用的 AI 工具上。
167
+
168
  ## 🌟 核心功能 (Features)
169
 
170
  ### 1. 🔌 全协议兼容 (Multi-Protocol API)
package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
  {
2
  "name": "codex-proxy",
3
- "version": "1.0.0",
4
  "lockfileVersion": 3,
5
  "requires": true,
6
  "packages": {
7
  "": {
8
  "name": "codex-proxy",
9
- "version": "1.0.0",
10
  "hasInstallScript": true,
11
  "dependencies": {
12
  "@hono/node-server": "^1.0.0",
@@ -19,6 +19,7 @@
19
  "@electron/asar": "^3.2.0",
20
  "@types/js-yaml": "^4.0.0",
21
  "@types/node": "^22.0.0",
 
22
  "electron-to-chromium": "^1.5.302",
23
  "js-beautify": "^1.15.0",
24
  "tsx": "^4.0.0",
@@ -29,6 +30,80 @@
29
  "koffi": "^2.15.1"
30
  }
31
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  "node_modules/@electron/asar": {
33
  "version": "3.4.1",
34
  "resolved": "https://registry.npmmirror.com/@electron/asar/-/asar-3.4.1.tgz",
@@ -519,6 +594,37 @@
519
  "node": ">=12"
520
  }
521
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
522
  "node_modules/@jridgewell/sourcemap-codec": {
523
  "version": "1.5.5",
524
  "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
@@ -526,6 +632,17 @@
526
  "dev": true,
527
  "license": "MIT"
528
  },
 
 
 
 
 
 
 
 
 
 
 
529
  "node_modules/@one-ini/wasm": {
530
  "version": "0.1.1",
531
  "resolved": "https://registry.npmmirror.com/@one-ini/wasm/-/wasm-0.1.1.tgz",
@@ -936,6 +1053,40 @@
936
  "undici-types": "~6.21.0"
937
  }
938
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
939
  "node_modules/@vitest/expect": {
940
  "version": "3.2.4",
941
  "resolved": "https://registry.npmmirror.com/@vitest/expect/-/expect-3.2.4.tgz",
@@ -1103,6 +1254,25 @@
1103
  "node": ">=12"
1104
  }
1105
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1106
  "node_modules/balanced-match": {
1107
  "version": "1.0.2",
1108
  "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -1486,6 +1656,16 @@
1486
  "url": "https://github.com/sponsors/isaacs"
1487
  }
1488
  },
 
 
 
 
 
 
 
 
 
 
1489
  "node_modules/hono": {
1490
  "version": "4.11.9",
1491
  "resolved": "https://registry.npmmirror.com/hono/-/hono-4.11.9.tgz",
@@ -1495,6 +1675,13 @@
1495
  "node": ">=16.9.0"
1496
  }
1497
  },
 
 
 
 
 
 
 
1498
  "node_modules/inflight": {
1499
  "version": "1.0.6",
1500
  "resolved": "https://registry.npmmirror.com/inflight/-/inflight-1.0.6.tgz",
@@ -1538,6 +1725,60 @@
1538
  "dev": true,
1539
  "license": "ISC"
1540
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1541
  "node_modules/jackspeak": {
1542
  "version": "3.4.3",
1543
  "resolved": "https://registry.npmmirror.com/jackspeak/-/jackspeak-3.4.3.tgz",
@@ -1687,6 +1928,34 @@
1687
  "@jridgewell/sourcemap-codec": "^1.5.5"
1688
  }
1689
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1690
  "node_modules/minimatch": {
1691
  "version": "3.1.2",
1692
  "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz",
@@ -2131,6 +2400,127 @@
2131
  "url": "https://github.com/sponsors/antfu"
2132
  }
2133
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2134
  "node_modules/tinybench": {
2135
  "version": "2.9.0",
2136
  "resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz",
 
1
  {
2
  "name": "codex-proxy",
3
+ "version": "1.0.28",
4
  "lockfileVersion": 3,
5
  "requires": true,
6
  "packages": {
7
  "": {
8
  "name": "codex-proxy",
9
+ "version": "1.0.28",
10
  "hasInstallScript": true,
11
  "dependencies": {
12
  "@hono/node-server": "^1.0.0",
 
19
  "@electron/asar": "^3.2.0",
20
  "@types/js-yaml": "^4.0.0",
21
  "@types/node": "^22.0.0",
22
+ "@vitest/coverage-v8": "^3.2.4",
23
  "electron-to-chromium": "^1.5.302",
24
  "js-beautify": "^1.15.0",
25
  "tsx": "^4.0.0",
 
30
  "koffi": "^2.15.1"
31
  }
32
  },
33
+ "node_modules/@ampproject/remapping": {
34
+ "version": "2.3.0",
35
+ "resolved": "https://registry.npmmirror.com/@ampproject/remapping/-/remapping-2.3.0.tgz",
36
+ "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
37
+ "dev": true,
38
+ "license": "Apache-2.0",
39
+ "dependencies": {
40
+ "@jridgewell/gen-mapping": "^0.3.5",
41
+ "@jridgewell/trace-mapping": "^0.3.24"
42
+ },
43
+ "engines": {
44
+ "node": ">=6.0.0"
45
+ }
46
+ },
47
+ "node_modules/@babel/helper-string-parser": {
48
+ "version": "7.27.1",
49
+ "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
50
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
51
+ "dev": true,
52
+ "license": "MIT",
53
+ "engines": {
54
+ "node": ">=6.9.0"
55
+ }
56
+ },
57
+ "node_modules/@babel/helper-validator-identifier": {
58
+ "version": "7.28.5",
59
+ "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
60
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
61
+ "dev": true,
62
+ "license": "MIT",
63
+ "engines": {
64
+ "node": ">=6.9.0"
65
+ }
66
+ },
67
+ "node_modules/@babel/parser": {
68
+ "version": "7.29.0",
69
+ "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.0.tgz",
70
+ "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
71
+ "dev": true,
72
+ "license": "MIT",
73
+ "dependencies": {
74
+ "@babel/types": "^7.29.0"
75
+ },
76
+ "bin": {
77
+ "parser": "bin/babel-parser.js"
78
+ },
79
+ "engines": {
80
+ "node": ">=6.0.0"
81
+ }
82
+ },
83
+ "node_modules/@babel/types": {
84
+ "version": "7.29.0",
85
+ "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz",
86
+ "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
87
+ "dev": true,
88
+ "license": "MIT",
89
+ "dependencies": {
90
+ "@babel/helper-string-parser": "^7.27.1",
91
+ "@babel/helper-validator-identifier": "^7.28.5"
92
+ },
93
+ "engines": {
94
+ "node": ">=6.9.0"
95
+ }
96
+ },
97
+ "node_modules/@bcoe/v8-coverage": {
98
+ "version": "1.0.2",
99
+ "resolved": "https://registry.npmmirror.com/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
100
+ "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
101
+ "dev": true,
102
+ "license": "MIT",
103
+ "engines": {
104
+ "node": ">=18"
105
+ }
106
+ },
107
  "node_modules/@electron/asar": {
108
  "version": "3.4.1",
109
  "resolved": "https://registry.npmmirror.com/@electron/asar/-/asar-3.4.1.tgz",
 
594
  "node": ">=12"
595
  }
596
  },
597
+ "node_modules/@istanbuljs/schema": {
598
+ "version": "0.1.3",
599
+ "resolved": "https://registry.npmmirror.com/@istanbuljs/schema/-/schema-0.1.3.tgz",
600
+ "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
601
+ "dev": true,
602
+ "license": "MIT",
603
+ "engines": {
604
+ "node": ">=8"
605
+ }
606
+ },
607
+ "node_modules/@jridgewell/gen-mapping": {
608
+ "version": "0.3.13",
609
+ "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
610
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
611
+ "dev": true,
612
+ "license": "MIT",
613
+ "dependencies": {
614
+ "@jridgewell/sourcemap-codec": "^1.5.0",
615
+ "@jridgewell/trace-mapping": "^0.3.24"
616
+ }
617
+ },
618
+ "node_modules/@jridgewell/resolve-uri": {
619
+ "version": "3.1.2",
620
+ "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
621
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
622
+ "dev": true,
623
+ "license": "MIT",
624
+ "engines": {
625
+ "node": ">=6.0.0"
626
+ }
627
+ },
628
  "node_modules/@jridgewell/sourcemap-codec": {
629
  "version": "1.5.5",
630
  "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
 
632
  "dev": true,
633
  "license": "MIT"
634
  },
635
+ "node_modules/@jridgewell/trace-mapping": {
636
+ "version": "0.3.31",
637
+ "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
638
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
639
+ "dev": true,
640
+ "license": "MIT",
641
+ "dependencies": {
642
+ "@jridgewell/resolve-uri": "^3.1.0",
643
+ "@jridgewell/sourcemap-codec": "^1.4.14"
644
+ }
645
+ },
646
  "node_modules/@one-ini/wasm": {
647
  "version": "0.1.1",
648
  "resolved": "https://registry.npmmirror.com/@one-ini/wasm/-/wasm-0.1.1.tgz",
 
1053
  "undici-types": "~6.21.0"
1054
  }
1055
  },
1056
+ "node_modules/@vitest/coverage-v8": {
1057
+ "version": "3.2.4",
1058
+ "resolved": "https://registry.npmmirror.com/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz",
1059
+ "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==",
1060
+ "dev": true,
1061
+ "license": "MIT",
1062
+ "dependencies": {
1063
+ "@ampproject/remapping": "^2.3.0",
1064
+ "@bcoe/v8-coverage": "^1.0.2",
1065
+ "ast-v8-to-istanbul": "^0.3.3",
1066
+ "debug": "^4.4.1",
1067
+ "istanbul-lib-coverage": "^3.2.2",
1068
+ "istanbul-lib-report": "^3.0.1",
1069
+ "istanbul-lib-source-maps": "^5.0.6",
1070
+ "istanbul-reports": "^3.1.7",
1071
+ "magic-string": "^0.30.17",
1072
+ "magicast": "^0.3.5",
1073
+ "std-env": "^3.9.0",
1074
+ "test-exclude": "^7.0.1",
1075
+ "tinyrainbow": "^2.0.0"
1076
+ },
1077
+ "funding": {
1078
+ "url": "https://opencollective.com/vitest"
1079
+ },
1080
+ "peerDependencies": {
1081
+ "@vitest/browser": "3.2.4",
1082
+ "vitest": "3.2.4"
1083
+ },
1084
+ "peerDependenciesMeta": {
1085
+ "@vitest/browser": {
1086
+ "optional": true
1087
+ }
1088
+ }
1089
+ },
1090
  "node_modules/@vitest/expect": {
1091
  "version": "3.2.4",
1092
  "resolved": "https://registry.npmmirror.com/@vitest/expect/-/expect-3.2.4.tgz",
 
1254
  "node": ">=12"
1255
  }
1256
  },
1257
+ "node_modules/ast-v8-to-istanbul": {
1258
+ "version": "0.3.12",
1259
+ "resolved": "https://registry.npmmirror.com/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz",
1260
+ "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==",
1261
+ "dev": true,
1262
+ "license": "MIT",
1263
+ "dependencies": {
1264
+ "@jridgewell/trace-mapping": "^0.3.31",
1265
+ "estree-walker": "^3.0.3",
1266
+ "js-tokens": "^10.0.0"
1267
+ }
1268
+ },
1269
+ "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
1270
+ "version": "10.0.0",
1271
+ "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-10.0.0.tgz",
1272
+ "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
1273
+ "dev": true,
1274
+ "license": "MIT"
1275
+ },
1276
  "node_modules/balanced-match": {
1277
  "version": "1.0.2",
1278
  "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz",
 
1656
  "url": "https://github.com/sponsors/isaacs"
1657
  }
1658
  },
1659
+ "node_modules/has-flag": {
1660
+ "version": "4.0.0",
1661
+ "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz",
1662
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
1663
+ "dev": true,
1664
+ "license": "MIT",
1665
+ "engines": {
1666
+ "node": ">=8"
1667
+ }
1668
+ },
1669
  "node_modules/hono": {
1670
  "version": "4.11.9",
1671
  "resolved": "https://registry.npmmirror.com/hono/-/hono-4.11.9.tgz",
 
1675
  "node": ">=16.9.0"
1676
  }
1677
  },
1678
+ "node_modules/html-escaper": {
1679
+ "version": "2.0.2",
1680
+ "resolved": "https://registry.npmmirror.com/html-escaper/-/html-escaper-2.0.2.tgz",
1681
+ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
1682
+ "dev": true,
1683
+ "license": "MIT"
1684
+ },
1685
  "node_modules/inflight": {
1686
  "version": "1.0.6",
1687
  "resolved": "https://registry.npmmirror.com/inflight/-/inflight-1.0.6.tgz",
 
1725
  "dev": true,
1726
  "license": "ISC"
1727
  },
1728
+ "node_modules/istanbul-lib-coverage": {
1729
+ "version": "3.2.2",
1730
+ "resolved": "https://registry.npmmirror.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
1731
+ "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
1732
+ "dev": true,
1733
+ "license": "BSD-3-Clause",
1734
+ "engines": {
1735
+ "node": ">=8"
1736
+ }
1737
+ },
1738
+ "node_modules/istanbul-lib-report": {
1739
+ "version": "3.0.1",
1740
+ "resolved": "https://registry.npmmirror.com/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
1741
+ "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
1742
+ "dev": true,
1743
+ "license": "BSD-3-Clause",
1744
+ "dependencies": {
1745
+ "istanbul-lib-coverage": "^3.0.0",
1746
+ "make-dir": "^4.0.0",
1747
+ "supports-color": "^7.1.0"
1748
+ },
1749
+ "engines": {
1750
+ "node": ">=10"
1751
+ }
1752
+ },
1753
+ "node_modules/istanbul-lib-source-maps": {
1754
+ "version": "5.0.6",
1755
+ "resolved": "https://registry.npmmirror.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz",
1756
+ "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==",
1757
+ "dev": true,
1758
+ "license": "BSD-3-Clause",
1759
+ "dependencies": {
1760
+ "@jridgewell/trace-mapping": "^0.3.23",
1761
+ "debug": "^4.1.1",
1762
+ "istanbul-lib-coverage": "^3.0.0"
1763
+ },
1764
+ "engines": {
1765
+ "node": ">=10"
1766
+ }
1767
+ },
1768
+ "node_modules/istanbul-reports": {
1769
+ "version": "3.2.0",
1770
+ "resolved": "https://registry.npmmirror.com/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
1771
+ "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
1772
+ "dev": true,
1773
+ "license": "BSD-3-Clause",
1774
+ "dependencies": {
1775
+ "html-escaper": "^2.0.0",
1776
+ "istanbul-lib-report": "^3.0.0"
1777
+ },
1778
+ "engines": {
1779
+ "node": ">=8"
1780
+ }
1781
+ },
1782
  "node_modules/jackspeak": {
1783
  "version": "3.4.3",
1784
  "resolved": "https://registry.npmmirror.com/jackspeak/-/jackspeak-3.4.3.tgz",
 
1928
  "@jridgewell/sourcemap-codec": "^1.5.5"
1929
  }
1930
  },
1931
+ "node_modules/magicast": {
1932
+ "version": "0.3.5",
1933
+ "resolved": "https://registry.npmmirror.com/magicast/-/magicast-0.3.5.tgz",
1934
+ "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==",
1935
+ "dev": true,
1936
+ "license": "MIT",
1937
+ "dependencies": {
1938
+ "@babel/parser": "^7.25.4",
1939
+ "@babel/types": "^7.25.4",
1940
+ "source-map-js": "^1.2.0"
1941
+ }
1942
+ },
1943
+ "node_modules/make-dir": {
1944
+ "version": "4.0.0",
1945
+ "resolved": "https://registry.npmmirror.com/make-dir/-/make-dir-4.0.0.tgz",
1946
+ "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
1947
+ "dev": true,
1948
+ "license": "MIT",
1949
+ "dependencies": {
1950
+ "semver": "^7.5.3"
1951
+ },
1952
+ "engines": {
1953
+ "node": ">=10"
1954
+ },
1955
+ "funding": {
1956
+ "url": "https://github.com/sponsors/sindresorhus"
1957
+ }
1958
+ },
1959
  "node_modules/minimatch": {
1960
  "version": "3.1.2",
1961
  "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz",
 
2400
  "url": "https://github.com/sponsors/antfu"
2401
  }
2402
  },
2403
+ "node_modules/supports-color": {
2404
+ "version": "7.2.0",
2405
+ "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz",
2406
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
2407
+ "dev": true,
2408
+ "license": "MIT",
2409
+ "dependencies": {
2410
+ "has-flag": "^4.0.0"
2411
+ },
2412
+ "engines": {
2413
+ "node": ">=8"
2414
+ }
2415
+ },
2416
+ "node_modules/test-exclude": {
2417
+ "version": "7.0.2",
2418
+ "resolved": "https://registry.npmmirror.com/test-exclude/-/test-exclude-7.0.2.tgz",
2419
+ "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==",
2420
+ "dev": true,
2421
+ "license": "ISC",
2422
+ "dependencies": {
2423
+ "@istanbuljs/schema": "^0.1.2",
2424
+ "glob": "^10.4.1",
2425
+ "minimatch": "^10.2.2"
2426
+ },
2427
+ "engines": {
2428
+ "node": ">=18"
2429
+ }
2430
+ },
2431
+ "node_modules/test-exclude/node_modules/balanced-match": {
2432
+ "version": "4.0.4",
2433
+ "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz",
2434
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
2435
+ "dev": true,
2436
+ "license": "MIT",
2437
+ "engines": {
2438
+ "node": "18 || 20 || >=22"
2439
+ }
2440
+ },
2441
+ "node_modules/test-exclude/node_modules/brace-expansion": {
2442
+ "version": "5.0.4",
2443
+ "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.4.tgz",
2444
+ "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
2445
+ "dev": true,
2446
+ "license": "MIT",
2447
+ "dependencies": {
2448
+ "balanced-match": "^4.0.2"
2449
+ },
2450
+ "engines": {
2451
+ "node": "18 || 20 || >=22"
2452
+ }
2453
+ },
2454
+ "node_modules/test-exclude/node_modules/glob": {
2455
+ "version": "10.4.5",
2456
+ "resolved": "https://registry.npmmirror.com/glob/-/glob-10.4.5.tgz",
2457
+ "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
2458
+ "dev": true,
2459
+ "license": "ISC",
2460
+ "dependencies": {
2461
+ "foreground-child": "^3.1.0",
2462
+ "jackspeak": "^3.1.2",
2463
+ "minimatch": "^9.0.4",
2464
+ "minipass": "^7.1.2",
2465
+ "package-json-from-dist": "^1.0.0",
2466
+ "path-scurry": "^1.11.1"
2467
+ },
2468
+ "bin": {
2469
+ "glob": "dist/esm/bin.mjs"
2470
+ },
2471
+ "funding": {
2472
+ "url": "https://github.com/sponsors/isaacs"
2473
+ }
2474
+ },
2475
+ "node_modules/test-exclude/node_modules/glob/node_modules/balanced-match": {
2476
+ "version": "1.0.2",
2477
+ "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz",
2478
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
2479
+ "dev": true,
2480
+ "license": "MIT"
2481
+ },
2482
+ "node_modules/test-exclude/node_modules/glob/node_modules/brace-expansion": {
2483
+ "version": "2.0.2",
2484
+ "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.2.tgz",
2485
+ "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
2486
+ "dev": true,
2487
+ "license": "MIT",
2488
+ "dependencies": {
2489
+ "balanced-match": "^1.0.0"
2490
+ }
2491
+ },
2492
+ "node_modules/test-exclude/node_modules/glob/node_modules/minimatch": {
2493
+ "version": "9.0.9",
2494
+ "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz",
2495
+ "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
2496
+ "dev": true,
2497
+ "license": "ISC",
2498
+ "dependencies": {
2499
+ "brace-expansion": "^2.0.2"
2500
+ },
2501
+ "engines": {
2502
+ "node": ">=16 || 14 >=14.17"
2503
+ },
2504
+ "funding": {
2505
+ "url": "https://github.com/sponsors/isaacs"
2506
+ }
2507
+ },
2508
+ "node_modules/test-exclude/node_modules/minimatch": {
2509
+ "version": "10.2.4",
2510
+ "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.4.tgz",
2511
+ "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
2512
+ "dev": true,
2513
+ "license": "BlueOak-1.0.0",
2514
+ "dependencies": {
2515
+ "brace-expansion": "^5.0.2"
2516
+ },
2517
+ "engines": {
2518
+ "node": "18 || 20 || >=22"
2519
+ },
2520
+ "funding": {
2521
+ "url": "https://github.com/sponsors/isaacs"
2522
+ }
2523
+ },
2524
  "node_modules/tinybench": {
2525
  "version": "2.9.0",
2526
  "resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz",
package.json CHANGED
@@ -34,6 +34,7 @@
34
  "@electron/asar": "^3.2.0",
35
  "@types/js-yaml": "^4.0.0",
36
  "@types/node": "^22.0.0",
 
37
  "electron-to-chromium": "^1.5.302",
38
  "js-beautify": "^1.15.0",
39
  "tsx": "^4.0.0",
 
34
  "@electron/asar": "^3.2.0",
35
  "@types/js-yaml": "^4.0.0",
36
  "@types/node": "^22.0.0",
37
+ "@vitest/coverage-v8": "^3.2.4",
38
  "electron-to-chromium": "^1.5.302",
39
  "js-beautify": "^1.15.0",
40
  "tsx": "^4.0.0",
shared/i18n/translations.ts CHANGED
@@ -24,6 +24,7 @@ export const translations = {
24
  totalAll: "Total",
25
  windowLabel: "window",
26
  rateLimit: "Rate Limit",
 
27
  limitReached: "Limit Reached",
28
  used: "Used",
29
  ok: "OK",
@@ -196,6 +197,7 @@ export const translations = {
196
  totalAll: "\u7d2f\u8ba1",
197
  windowLabel: "\u7a97\u53e3",
198
  rateLimit: "\u901f\u7387\u9650\u5236",
 
199
  limitReached: "\u5df2\u8fbe\u4e0a\u9650",
200
  used: "\u5df2\u4f7f\u7528",
201
  ok: "\u6b63\u5e38",
 
24
  totalAll: "Total",
25
  windowLabel: "window",
26
  rateLimit: "Rate Limit",
27
+ secondaryRateLimit: "Weekly Limit",
28
  limitReached: "Limit Reached",
29
  used: "Used",
30
  ok: "OK",
 
197
  totalAll: "\u7d2f\u8ba1",
198
  windowLabel: "\u7a97\u53e3",
199
  rateLimit: "\u901f\u7387\u9650\u5236",
200
+ secondaryRateLimit: "\u5468\u9650\u5236",
201
  limitReached: "\u5df2\u8fbe\u4e0a\u9650",
202
  used: "\u5df2\u4f7f\u7528",
203
  ok: "\u6b63\u5e38",
shared/types.d.ts ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export interface AccountQuotaWindow {
2
+ used_percent?: number | null;
3
+ limit_reached?: boolean;
4
+ reset_at?: number | null;
5
+ limit_window_seconds?: number | null;
6
+ }
7
+ export interface AccountQuota {
8
+ rate_limit?: AccountQuotaWindow;
9
+ /** Secondary rate limit window (e.g. weekly cap). */
10
+ secondary_rate_limit?: AccountQuotaWindow | null;
11
+ }
12
+ export interface Account {
13
+ id: string;
14
+ email: string;
15
+ status: string;
16
+ planType?: string;
17
+ usage?: {
18
+ request_count?: number;
19
+ input_tokens?: number;
20
+ output_tokens?: number;
21
+ window_request_count?: number;
22
+ window_input_tokens?: number;
23
+ window_output_tokens?: number;
24
+ };
25
+ quota?: AccountQuota;
26
+ proxyId?: string;
27
+ proxyName?: string;
28
+ }
29
+ export interface ProxyHealthInfo {
30
+ exitIp: string | null;
31
+ latencyMs: number;
32
+ lastChecked: string;
33
+ error: string | null;
34
+ }
35
+ export interface ProxyEntry {
36
+ id: string;
37
+ name: string;
38
+ url: string;
39
+ status: "active" | "unreachable" | "disabled";
40
+ health: ProxyHealthInfo | null;
41
+ addedAt: string;
42
+ }
43
+ export interface ProxyAssignment {
44
+ accountId: string;
45
+ proxyId: string;
46
+ }
47
+ export type DiagnosticStatus = "pass" | "fail" | "skip";
48
+ export interface DiagnosticCheck {
49
+ name: string;
50
+ status: DiagnosticStatus;
51
+ latencyMs: number;
52
+ detail: string | null;
53
+ error: string | null;
54
+ }
55
+ export interface TestConnectionResult {
56
+ checks: DiagnosticCheck[];
57
+ overall: DiagnosticStatus;
58
+ timestamp: string;
59
+ }
shared/types.js ADDED
@@ -0,0 +1 @@
 
 
1
+ export {};
src/auth/__tests__/account-pool.test.ts DELETED
@@ -1,292 +0,0 @@
1
- /**
2
- * Tests for AccountPool core scheduling logic.
3
- *
4
- * Uses vi.mock to stub filesystem and JWT utilities so tests run
5
- * without actual data files or valid JWT tokens.
6
- */
7
-
8
- import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
9
-
10
- // Mock fs before importing AccountPool
11
- vi.mock("fs", () => ({
12
- readFileSync: vi.fn(() => { throw new Error("ENOENT"); }),
13
- writeFileSync: vi.fn(),
14
- renameSync: vi.fn(),
15
- existsSync: vi.fn(() => false),
16
- mkdirSync: vi.fn(),
17
- }));
18
-
19
- // Mock config
20
- vi.mock("../../config.js", () => ({
21
- getConfig: vi.fn(() => ({
22
- auth: {
23
- jwt_token: null,
24
- rotation_strategy: "least_used",
25
- rate_limit_backoff_seconds: 60,
26
- },
27
- server: {
28
- proxy_api_key: null,
29
- },
30
- })),
31
- }));
32
-
33
- // Mock JWT utilities — all tokens are "valid"
34
- vi.mock("../jwt-utils.js", () => ({
35
- decodeJwtPayload: vi.fn(() => ({ exp: Math.floor(Date.now() / 1000) + 3600 })),
36
- extractChatGptAccountId: vi.fn((token: string) => `acct-${token.slice(0, 8)}`),
37
- extractUserProfile: vi.fn((token: string) => ({
38
- email: `${token.slice(0, 4)}@test.com`,
39
- chatgpt_plan_type: "free",
40
- })),
41
- isTokenExpired: vi.fn(() => false),
42
- }));
43
-
44
- // Mock jitter to return the exact value (no randomness in tests)
45
- vi.mock("../../utils/jitter.js", () => ({
46
- jitter: vi.fn((val: number) => val),
47
- }));
48
-
49
- import { AccountPool } from "../account-pool.js";
50
- import { getConfig } from "../../config.js";
51
- import { isTokenExpired } from "../jwt-utils.js";
52
-
53
- describe("AccountPool", () => {
54
- let pool: AccountPool;
55
-
56
- beforeEach(() => {
57
- // Reset mock implementations to defaults (clearAllMocks only clears call history)
58
- vi.mocked(isTokenExpired).mockReturnValue(false);
59
- vi.mocked(getConfig).mockReturnValue({
60
- auth: {
61
- jwt_token: null,
62
- rotation_strategy: "least_used",
63
- rate_limit_backoff_seconds: 60,
64
- },
65
- server: { proxy_api_key: null },
66
- } as ReturnType<typeof getConfig>);
67
- pool = new AccountPool();
68
- });
69
-
70
- afterEach(() => {
71
- pool.destroy();
72
- });
73
-
74
- describe("addAccount + acquire", () => {
75
- it("adds an account and acquires it", () => {
76
- pool.addAccount("token-aaa");
77
-
78
- const acquired = pool.acquire();
79
- expect(acquired).not.toBeNull();
80
- expect(acquired!.token).toBe("token-aaa");
81
- });
82
-
83
- it("deduplicates by accountId", () => {
84
- const id1 = pool.addAccount("token-aaa");
85
- const id2 = pool.addAccount("token-aaa"); // same prefix → same accountId
86
-
87
- expect(id1).toBe(id2);
88
- });
89
-
90
- it("returns null when no accounts exist", () => {
91
- expect(pool.acquire()).toBeNull();
92
- });
93
- });
94
-
95
- describe("least_used rotation", () => {
96
- it("selects the account with lowest request_count", () => {
97
- pool.addAccount("token-aaa");
98
- pool.addAccount("token-bbb");
99
-
100
- // Use account A once
101
- const first = pool.acquire()!;
102
- pool.release(first.entryId, { input_tokens: 10, output_tokens: 5 });
103
-
104
- // Next acquire should pick the other account (0 requests)
105
- const second = pool.acquire()!;
106
- expect(second.entryId).not.toBe(first.entryId);
107
- });
108
- });
109
-
110
- describe("round_robin rotation", () => {
111
- it("cycles through accounts in order", () => {
112
- vi.mocked(getConfig).mockReturnValue({
113
- auth: {
114
- jwt_token: null,
115
- rotation_strategy: "round_robin",
116
- rate_limit_backoff_seconds: 60,
117
- },
118
- server: { proxy_api_key: null },
119
- } as ReturnType<typeof getConfig>);
120
-
121
- // Create a fresh pool with round_robin config
122
- const rrPool = new AccountPool();
123
- rrPool.addAccount("token-aaa");
124
- rrPool.addAccount("token-bbb");
125
-
126
- const a1 = rrPool.acquire()!;
127
- rrPool.release(a1.entryId);
128
-
129
- const a2 = rrPool.acquire()!;
130
- rrPool.release(a2.entryId);
131
-
132
- const a3 = rrPool.acquire()!;
133
- rrPool.release(a3.entryId);
134
-
135
- // a3 should wrap around to same as a1
136
- expect(a3.entryId).toBe(a1.entryId);
137
- expect(a1.entryId).not.toBe(a2.entryId);
138
-
139
- rrPool.destroy();
140
- });
141
- });
142
-
143
- describe("release", () => {
144
- it("increments request_count and token usage", () => {
145
- pool.addAccount("token-aaa");
146
-
147
- const acquired = pool.acquire()!;
148
- pool.release(acquired.entryId, { input_tokens: 100, output_tokens: 50 });
149
-
150
- const accounts = pool.getAccounts();
151
- expect(accounts[0].usage.request_count).toBe(1);
152
- expect(accounts[0].usage.input_tokens).toBe(100);
153
- expect(accounts[0].usage.output_tokens).toBe(50);
154
- expect(accounts[0].usage.last_used).not.toBeNull();
155
- });
156
-
157
- it("unlocks account after release", () => {
158
- pool.addAccount("token-aaa");
159
-
160
- const a1 = pool.acquire()!;
161
- // While locked, acquire returns null (only 1 account)
162
- expect(pool.acquire()).toBeNull();
163
-
164
- pool.release(a1.entryId);
165
- // After release, can acquire again
166
- expect(pool.acquire()).not.toBeNull();
167
- });
168
- });
169
-
170
- describe("markRateLimited", () => {
171
- it("marks account as rate_limited and skips it in acquire", () => {
172
- pool.addAccount("token-aaa");
173
- pool.addAccount("token-bbb");
174
-
175
- const first = pool.acquire()!;
176
- pool.markRateLimited(first.entryId);
177
-
178
- // Pool summary should show 1 rate_limited
179
- const summary = pool.getPoolSummary();
180
- expect(summary.rate_limited).toBe(1);
181
- expect(summary.active).toBe(1);
182
-
183
- // Next acquire should skip the rate-limited account
184
- const second = pool.acquire()!;
185
- expect(second.entryId).not.toBe(first.entryId);
186
- });
187
-
188
- it("countRequest option increments usage on 429", () => {
189
- pool.addAccount("token-aaa");
190
-
191
- const acquired = pool.acquire()!;
192
- pool.markRateLimited(acquired.entryId, { countRequest: true });
193
-
194
- const accounts = pool.getAccounts();
195
- expect(accounts[0].usage.request_count).toBe(1);
196
- });
197
-
198
- it("auto-recovers after rate_limit_until passes", () => {
199
- pool.addAccount("token-aaa");
200
-
201
- const acquired = pool.acquire()!;
202
- // Set rate limit to already expired
203
- pool.markRateLimited(acquired.entryId, { retryAfterSec: -1 });
204
-
205
- // refreshStatus should detect the expired rate_limit_until
206
- const summary = pool.getPoolSummary();
207
- expect(summary.active).toBe(1);
208
- expect(summary.rate_limited).toBe(0);
209
- });
210
- });
211
-
212
- describe("stale lock auto-release", () => {
213
- it("releases locks older than 5 minutes", () => {
214
- pool.addAccount("token-aaa");
215
-
216
- const acquired = pool.acquire()!;
217
-
218
- // Manually backdate the lock by manipulating the acquireLocks map
219
- // Access private field for testing — unavoidable for TTL tests
220
- const locks = (pool as unknown as { acquireLocks: Map<string, number> }).acquireLocks;
221
- locks.set(acquired.entryId, Date.now() - 6 * 60 * 1000); // 6 minutes ago
222
-
223
- // Next acquire should auto-release the stale lock and return the same account
224
- const reacquired = pool.acquire()!;
225
- expect(reacquired).not.toBeNull();
226
- expect(reacquired.entryId).toBe(acquired.entryId);
227
- });
228
- });
229
-
230
- describe("expired tokens", () => {
231
- it("skips expired accounts in acquire", () => {
232
- vi.mocked(isTokenExpired).mockReturnValue(true);
233
- pool.addAccount("token-expired");
234
-
235
- expect(pool.acquire()).toBeNull();
236
- expect(pool.getPoolSummary().expired).toBe(1);
237
- });
238
- });
239
-
240
- describe("removeAccount", () => {
241
- it("removes an account and clears its lock", () => {
242
- pool.addAccount("token-aaa");
243
-
244
- const acquired = pool.acquire()!;
245
- pool.removeAccount(acquired.entryId);
246
-
247
- expect(pool.getPoolSummary().total).toBe(0);
248
- expect(pool.acquire()).toBeNull();
249
- });
250
- });
251
-
252
- describe("resetUsage", () => {
253
- it("resets counters to zero", () => {
254
- pool.addAccount("token-aaa");
255
-
256
- const acquired = pool.acquire()!;
257
- pool.release(acquired.entryId, { input_tokens: 100, output_tokens: 50 });
258
- pool.resetUsage(acquired.entryId);
259
-
260
- const accounts = pool.getAccounts();
261
- expect(accounts[0].usage.request_count).toBe(0);
262
- expect(accounts[0].usage.input_tokens).toBe(0);
263
- expect(accounts[0].usage.output_tokens).toBe(0);
264
- });
265
- });
266
-
267
- describe("validateProxyApiKey", () => {
268
- it("validates per-account proxy API key", () => {
269
- pool.addAccount("token-aaa");
270
-
271
- const accounts = pool.getAccounts();
272
- // Each account gets a generated proxyApiKey — we can't predict it,
273
- // but we can read it and validate
274
- const entry = pool.getEntry(accounts[0].id)!;
275
- expect(pool.validateProxyApiKey(entry.proxyApiKey)).toBe(true);
276
- expect(pool.validateProxyApiKey("wrong-key")).toBe(false);
277
- });
278
-
279
- it("validates config-level proxy API key", () => {
280
- vi.mocked(getConfig).mockReturnValue({
281
- auth: {
282
- jwt_token: null,
283
- rotation_strategy: "least_used",
284
- rate_limit_backoff_seconds: 60,
285
- },
286
- server: { proxy_api_key: "global-key-123" },
287
- } as ReturnType<typeof getConfig>);
288
-
289
- expect(pool.validateProxyApiKey("global-key-123")).toBe(true);
290
- });
291
- });
292
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/auth/types.ts CHANGED
@@ -56,16 +56,24 @@ export interface AccountInfo {
56
  quota?: CodexQuota;
57
  }
58
 
 
 
 
 
 
 
 
59
  /** Official Codex quota from /backend-api/codex/usage */
60
  export interface CodexQuota {
61
  plan_type: string;
62
- rate_limit: {
63
  allowed: boolean;
64
  limit_reached: boolean;
65
- used_percent: number | null;
66
- reset_at: number | null;
67
- limit_window_seconds: number | null;
68
  };
 
 
 
 
69
  code_review_rate_limit: {
70
  allowed: boolean;
71
  limit_reached: boolean;
 
56
  quota?: CodexQuota;
57
  }
58
 
59
+ /** A single rate limit window (primary or secondary). */
60
+ export interface CodexQuotaWindow {
61
+ used_percent: number | null;
62
+ reset_at: number | null;
63
+ limit_window_seconds: number | null;
64
+ }
65
+
66
  /** Official Codex quota from /backend-api/codex/usage */
67
  export interface CodexQuota {
68
  plan_type: string;
69
+ rate_limit: CodexQuotaWindow & {
70
  allowed: boolean;
71
  limit_reached: boolean;
 
 
 
72
  };
73
+ /** Secondary rate limit window (e.g. weekly cap). Null when backend doesn't report one. */
74
+ secondary_rate_limit: CodexQuotaWindow & {
75
+ limit_reached: boolean;
76
+ } | null;
77
  code_review_rate_limit: {
78
  allowed: boolean;
79
  limit_reached: boolean;
src/proxy/__tests__/codex-api.test.ts DELETED
@@ -1,201 +0,0 @@
1
- /**
2
- * Tests for CodexApi SSE parsing.
3
- *
4
- * parseStream() is the most fragile code path — it processes real-time
5
- * byte streams from curl where chunks can split at any boundary.
6
- */
7
-
8
- import { describe, it, expect } from "vitest";
9
- import { CodexApi, type CodexSSEEvent } from "../codex-api.js";
10
-
11
- /** Create a Response whose body emits the given string chunks sequentially. */
12
- function mockResponse(...chunks: string[]): Response {
13
- const encoder = new TextEncoder();
14
- let idx = 0;
15
- const stream = new ReadableStream<Uint8Array>({
16
- pull(controller) {
17
- if (idx < chunks.length) {
18
- controller.enqueue(encoder.encode(chunks[idx]));
19
- idx++;
20
- } else {
21
- controller.close();
22
- }
23
- },
24
- });
25
- return new Response(stream);
26
- }
27
-
28
- /** Collect all events from parseStream into an array. */
29
- async function collectEvents(api: CodexApi, response: Response): Promise<CodexSSEEvent[]> {
30
- const events: CodexSSEEvent[] = [];
31
- for await (const evt of api.parseStream(response)) {
32
- events.push(evt);
33
- }
34
- return events;
35
- }
36
-
37
- // CodexApi constructor requires a token — value is irrelevant for parsing tests
38
- function createApi(): CodexApi {
39
- return new CodexApi("test-token", null);
40
- }
41
-
42
- describe("CodexApi.parseStream", () => {
43
- it("parses a complete SSE event in a single chunk", async () => {
44
- const api = createApi();
45
- const response = mockResponse(
46
- 'event: response.output_text.delta\ndata: {"delta":"Hello"}\n\n',
47
- );
48
-
49
- const events = await collectEvents(api, response);
50
- expect(events).toHaveLength(1);
51
- expect(events[0].event).toBe("response.output_text.delta");
52
- expect(events[0].data).toEqual({ delta: "Hello" });
53
- });
54
-
55
- it("handles multiple events in a single chunk", async () => {
56
- const api = createApi();
57
- const response = mockResponse(
58
- 'event: response.created\ndata: {"response":{"id":"resp_1"}}\n\n' +
59
- 'event: response.output_text.delta\ndata: {"delta":"Hi"}\n\n' +
60
- 'event: response.completed\ndata: {"response":{"id":"resp_1","usage":{"input_tokens":10,"output_tokens":5}}}\n\n',
61
- );
62
-
63
- const events = await collectEvents(api, response);
64
- expect(events).toHaveLength(3);
65
- expect(events[0].event).toBe("response.created");
66
- expect(events[1].event).toBe("response.output_text.delta");
67
- expect(events[2].event).toBe("response.completed");
68
- });
69
-
70
- it("reassembles events split across chunk boundaries", async () => {
71
- const api = createApi();
72
- // Split in the middle of the JSON data
73
- const response = mockResponse(
74
- 'event: response.output_text.delta\ndata: {"del',
75
- 'ta":"world"}\n\n',
76
- );
77
-
78
- const events = await collectEvents(api, response);
79
- expect(events).toHaveLength(1);
80
- expect(events[0].data).toEqual({ delta: "world" });
81
- });
82
-
83
- it("handles chunk split at \\n\\n boundary", async () => {
84
- const api = createApi();
85
- // First chunk ends with first \n, second starts with second \n
86
- const response = mockResponse(
87
- 'event: response.output_text.delta\ndata: {"delta":"a"}\n',
88
- '\nevent: response.output_text.delta\ndata: {"delta":"b"}\n\n',
89
- );
90
-
91
- const events = await collectEvents(api, response);
92
- expect(events).toHaveLength(2);
93
- expect(events[0].data).toEqual({ delta: "a" });
94
- expect(events[1].data).toEqual({ delta: "b" });
95
- });
96
-
97
- it("handles many small single-character chunks", async () => {
98
- const api = createApi();
99
- const full = 'event: response.output_text.delta\ndata: {"delta":"x"}\n\n';
100
- // Split into individual characters
101
- const chunks = full.split("");
102
- const response = mockResponse(...chunks);
103
-
104
- const events = await collectEvents(api, response);
105
- expect(events).toHaveLength(1);
106
- expect(events[0].data).toEqual({ delta: "x" });
107
- });
108
-
109
- it("skips [DONE] marker without crashing", async () => {
110
- const api = createApi();
111
- const response = mockResponse(
112
- 'event: response.output_text.delta\ndata: {"delta":"hi"}\n\n' +
113
- "data: [DONE]\n\n",
114
- );
115
-
116
- const events = await collectEvents(api, response);
117
- expect(events).toHaveLength(1);
118
- expect(events[0].data).toEqual({ delta: "hi" });
119
- });
120
-
121
- it("returns raw string when data is not valid JSON", async () => {
122
- const api = createApi();
123
- const response = mockResponse(
124
- 'event: response.output_text.delta\ndata: not-json-at-all\n\n',
125
- );
126
-
127
- const events = await collectEvents(api, response);
128
- expect(events).toHaveLength(1);
129
- expect(events[0].data).toBe("not-json-at-all");
130
- });
131
-
132
- it("handles malformed JSON (unclosed brace) gracefully", async () => {
133
- const api = createApi();
134
- const response = mockResponse(
135
- 'event: response.output_text.delta\ndata: {"delta":"unclosed\n\n',
136
- );
137
-
138
- const events = await collectEvents(api, response);
139
- expect(events).toHaveLength(1);
140
- // Should not throw — falls through to raw string
141
- expect(typeof events[0].data).toBe("string");
142
- });
143
-
144
- it("skips empty blocks between events", async () => {
145
- const api = createApi();
146
- const response = mockResponse(
147
- 'event: response.output_text.delta\ndata: {"delta":"a"}\n\n' +
148
- "\n\n" + // empty block
149
- 'event: response.output_text.delta\ndata: {"delta":"b"}\n\n',
150
- );
151
-
152
- const events = await collectEvents(api, response);
153
- expect(events).toHaveLength(2);
154
- });
155
-
156
- it("processes remaining buffer after stream ends", async () => {
157
- const api = createApi();
158
- // No trailing \n\n — the event is only in the residual buffer
159
- const response = mockResponse(
160
- 'event: response.output_text.delta\ndata: {"delta":"last"}',
161
- );
162
-
163
- const events = await collectEvents(api, response);
164
- expect(events).toHaveLength(1);
165
- expect(events[0].data).toEqual({ delta: "last" });
166
- });
167
-
168
- it("handles multi-line data fields", async () => {
169
- const api = createApi();
170
- const response = mockResponse(
171
- 'event: response.output_text.delta\ndata: {"delta":\n' +
172
- 'data: "multi-line"}\n\n',
173
- );
174
-
175
- const events = await collectEvents(api, response);
176
- expect(events).toHaveLength(1);
177
- // data lines are joined with \n: '{"delta":\n"multi-line"}'
178
- expect(events[0].data).toEqual({ delta: "multi-line" });
179
- });
180
-
181
- it("returns null body error", async () => {
182
- const api = createApi();
183
- // Create a response with null body
184
- const response = new Response(null);
185
-
186
- await expect(async () => {
187
- await collectEvents(api, response);
188
- }).rejects.toThrow("Response body is null");
189
- });
190
-
191
- it("throws on buffer overflow (>10MB)", async () => {
192
- const api = createApi();
193
- // Create a chunk that exceeds the 10MB SSE buffer limit
194
- const hugeData = "x".repeat(11 * 1024 * 1024);
195
- const response = mockResponse(hugeData);
196
-
197
- await expect(async () => {
198
- await collectEvents(api, response);
199
- }).rejects.toThrow("SSE buffer exceeded");
200
- });
201
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/routes/accounts.ts CHANGED
@@ -26,6 +26,7 @@ import type { CookieJar } from "../proxy/cookie-jar.js";
26
  import type { ProxyPool } from "../proxy/proxy-pool.js";
27
 
28
  function toQuota(usage: CodexUsageResponse): CodexQuota {
 
29
  return {
30
  plan_type: usage.plan_type,
31
  rate_limit: {
@@ -35,6 +36,14 @@ function toQuota(usage: CodexUsageResponse): CodexQuota {
35
  reset_at: usage.rate_limit.primary_window?.reset_at ?? null,
36
  limit_window_seconds: usage.rate_limit.primary_window?.limit_window_seconds ?? null,
37
  },
 
 
 
 
 
 
 
 
38
  code_review_rate_limit: usage.code_review_rate_limit
39
  ? {
40
  allowed: usage.code_review_rate_limit.allowed,
 
26
  import type { ProxyPool } from "../proxy/proxy-pool.js";
27
 
28
  function toQuota(usage: CodexUsageResponse): CodexQuota {
29
+ const sw = usage.rate_limit.secondary_window;
30
  return {
31
  plan_type: usage.plan_type,
32
  rate_limit: {
 
36
  reset_at: usage.rate_limit.primary_window?.reset_at ?? null,
37
  limit_window_seconds: usage.rate_limit.primary_window?.limit_window_seconds ?? null,
38
  },
39
+ secondary_rate_limit: sw
40
+ ? {
41
+ limit_reached: usage.rate_limit.limit_reached,
42
+ used_percent: sw.used_percent ?? null,
43
+ reset_at: sw.reset_at ?? null,
44
+ limit_window_seconds: sw.limit_window_seconds ?? null,
45
+ }
46
+ : null,
47
  code_review_rate_limit: usage.code_review_rate_limit
48
  ? {
49
  allowed: usage.code_review_rate_limit.allowed,
src/translation/__tests__/codex-event-extractor.test.ts DELETED
@@ -1,182 +0,0 @@
1
- /**
2
- * Tests for parseCodexEvent — the shared event type parser.
3
- *
4
- * Tests the discriminated union mapping from raw SSE events to typed events.
5
- * This is the IR hub of the hub-and-spoke translation architecture.
6
- */
7
-
8
- import { describe, it, expect } from "vitest";
9
- import { parseCodexEvent } from "../../types/codex-events.js";
10
- import type { CodexSSEEvent } from "../../proxy/codex-api.js";
11
-
12
- describe("parseCodexEvent", () => {
13
- it("parses response.created with id", () => {
14
- const raw: CodexSSEEvent = {
15
- event: "response.created",
16
- data: { response: { id: "resp_abc123" } },
17
- };
18
- const typed = parseCodexEvent(raw);
19
- expect(typed.type).toBe("response.created");
20
- if (typed.type === "response.created") {
21
- expect(typed.response.id).toBe("resp_abc123");
22
- }
23
- });
24
-
25
- it("parses response.in_progress", () => {
26
- const raw: CodexSSEEvent = {
27
- event: "response.in_progress",
28
- data: { response: { id: "resp_abc123" } },
29
- };
30
- const typed = parseCodexEvent(raw);
31
- expect(typed.type).toBe("response.in_progress");
32
- if (typed.type === "response.in_progress") {
33
- expect(typed.response.id).toBe("resp_abc123");
34
- }
35
- });
36
-
37
- it("parses response.output_text.delta", () => {
38
- const raw: CodexSSEEvent = {
39
- event: "response.output_text.delta",
40
- data: { delta: "Hello, world!" },
41
- };
42
- const typed = parseCodexEvent(raw);
43
- expect(typed.type).toBe("response.output_text.delta");
44
- if (typed.type === "response.output_text.delta") {
45
- expect(typed.delta).toBe("Hello, world!");
46
- }
47
- });
48
-
49
- it("parses response.output_text.done", () => {
50
- const raw: CodexSSEEvent = {
51
- event: "response.output_text.done",
52
- data: { text: "Complete response text" },
53
- };
54
- const typed = parseCodexEvent(raw);
55
- expect(typed.type).toBe("response.output_text.done");
56
- if (typed.type === "response.output_text.done") {
57
- expect(typed.text).toBe("Complete response text");
58
- }
59
- });
60
-
61
- it("parses response.completed with usage", () => {
62
- const raw: CodexSSEEvent = {
63
- event: "response.completed",
64
- data: {
65
- response: {
66
- id: "resp_abc123",
67
- usage: { input_tokens: 150, output_tokens: 42 },
68
- },
69
- },
70
- };
71
- const typed = parseCodexEvent(raw);
72
- expect(typed.type).toBe("response.completed");
73
- if (typed.type === "response.completed") {
74
- expect(typed.response.id).toBe("resp_abc123");
75
- expect(typed.response.usage).toEqual({
76
- input_tokens: 150,
77
- output_tokens: 42,
78
- });
79
- }
80
- });
81
-
82
- it("parses response.completed without usage", () => {
83
- const raw: CodexSSEEvent = {
84
- event: "response.completed",
85
- data: { response: { id: "resp_abc123" } },
86
- };
87
- const typed = parseCodexEvent(raw);
88
- expect(typed.type).toBe("response.completed");
89
- if (typed.type === "response.completed") {
90
- expect(typed.response.usage).toBeUndefined();
91
- }
92
- });
93
-
94
- it("returns unknown for unrecognized event types", () => {
95
- const raw: CodexSSEEvent = {
96
- event: "response.some_future_event",
97
- data: { foo: "bar" },
98
- };
99
- const typed = parseCodexEvent(raw);
100
- expect(typed.type).toBe("unknown");
101
- if (typed.type === "unknown") {
102
- expect(typed.raw).toEqual({ foo: "bar" });
103
- }
104
- });
105
-
106
- it("returns unknown when response.created has no response object", () => {
107
- const raw: CodexSSEEvent = {
108
- event: "response.created",
109
- data: "not an object",
110
- };
111
- const typed = parseCodexEvent(raw);
112
- expect(typed.type).toBe("unknown");
113
- });
114
-
115
- it("returns unknown when response.created data has no response field", () => {
116
- const raw: CodexSSEEvent = {
117
- event: "response.created",
118
- data: { something_else: true },
119
- };
120
- const typed = parseCodexEvent(raw);
121
- expect(typed.type).toBe("unknown");
122
- });
123
-
124
- it("returns unknown when delta is not a string", () => {
125
- const raw: CodexSSEEvent = {
126
- event: "response.output_text.delta",
127
- data: { delta: 123 },
128
- };
129
- const typed = parseCodexEvent(raw);
130
- expect(typed.type).toBe("unknown");
131
- });
132
-
133
- it("handles empty delta string", () => {
134
- const raw: CodexSSEEvent = {
135
- event: "response.output_text.delta",
136
- data: { delta: "" },
137
- };
138
- const typed = parseCodexEvent(raw);
139
- expect(typed.type).toBe("response.output_text.delta");
140
- if (typed.type === "response.output_text.delta") {
141
- expect(typed.delta).toBe("");
142
- }
143
- });
144
-
145
- it("defaults usage token counts to 0 for non-numeric values", () => {
146
- const raw: CodexSSEEvent = {
147
- event: "response.completed",
148
- data: {
149
- response: {
150
- id: "resp_1",
151
- usage: { input_tokens: "not a number", output_tokens: null },
152
- },
153
- },
154
- };
155
- const typed = parseCodexEvent(raw);
156
- expect(typed.type).toBe("response.completed");
157
- if (typed.type === "response.completed") {
158
- expect(typed.response.usage).toEqual({
159
- input_tokens: 0,
160
- output_tokens: 0,
161
- });
162
- }
163
- });
164
-
165
- it("handles null data", () => {
166
- const raw: CodexSSEEvent = {
167
- event: "response.created",
168
- data: null,
169
- };
170
- const typed = parseCodexEvent(raw);
171
- expect(typed.type).toBe("unknown");
172
- });
173
-
174
- it("handles array data", () => {
175
- const raw: CodexSSEEvent = {
176
- event: "response.output_text.delta",
177
- data: [1, 2, 3],
178
- };
179
- const typed = parseCodexEvent(raw);
180
- expect(typed.type).toBe("unknown");
181
- });
182
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
web/src/components/AccountCard.tsx CHANGED
@@ -66,7 +66,7 @@ export function AccountCard({ account, index, onDelete, proxies, onProxyChange }
66
  if (err) alert(err);
67
  }, [account.id, onDelete, t]);
68
 
69
- // Quota
70
  const q = account.quota;
71
  const rl = q?.rate_limit;
72
  const pct = rl?.used_percent != null ? Math.round(rl.used_percent) : null;
@@ -82,6 +82,23 @@ export function AccountCard({ account, index, onDelete, proxies, onProxyChange }
82
  : "text-primary";
83
  const resetAt = rl?.reset_at ? formatResetTime(rl.reset_at, lang === "zh") : null;
84
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  return (
86
  <div class="bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl p-4 shadow-sm hover:shadow-md transition-all hover:border-primary/30 dark:hover:border-primary/50">
87
  {/* Header */}
@@ -158,37 +175,77 @@ export function AccountCard({ account, index, onDelete, proxies, onProxyChange }
158
  </div>
159
  )}
160
 
161
- {/* Quota bar */}
162
- {rl && (
163
- <div class="pt-3 mt-3 border-t border-slate-100 dark:border-border-dark">
164
- <div class="flex justify-between text-[0.78rem] mb-1.5">
165
- <span class="text-slate-500 dark:text-text-dim">{t("rateLimit")}</span>
166
- {rl.limit_reached ? (
167
- <span class="px-2 py-0.5 rounded-full bg-red-100 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-xs font-medium">
168
- {t("limitReached")}
169
- </span>
170
- ) : pct != null ? (
171
- <span class={`font-medium ${pctColor}`}>
172
- {pct}% {t("used")}
173
- </span>
174
- ) : (
175
- <span class="font-medium text-primary">{t("ok")}</span>
176
- )}
177
- </div>
178
- {pct != null && (
179
- <div class="w-full bg-slate-100 dark:bg-border-dark rounded-full h-2 overflow-hidden">
180
- <div class={`${barColor} h-2 rounded-full transition-all`} style={{ width: `${pct}%` }} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
  </div>
182
  )}
183
- {resetAt && (
184
- <p class="text-xs text-slate-400 dark:text-text-dim mt-1">
185
- {t("resetsAt")} {resetAt}
186
- {windowDur && (
187
- <span class="ml-1 text-slate-400 dark:text-text-dim/70">
188
- ({t("windowLabel")} {windowDur})
 
 
 
 
189
  </span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
  )}
191
- </p>
 
 
 
 
 
192
  )}
193
  </div>
194
  )}
 
66
  if (err) alert(err);
67
  }, [account.id, onDelete, t]);
68
 
69
+ // Quota — primary window
70
  const q = account.quota;
71
  const rl = q?.rate_limit;
72
  const pct = rl?.used_percent != null ? Math.round(rl.used_percent) : null;
 
82
  : "text-primary";
83
  const resetAt = rl?.reset_at ? formatResetTime(rl.reset_at, lang === "zh") : null;
84
 
85
+ // Quota — secondary window (e.g. weekly)
86
+ const srl = q?.secondary_rate_limit;
87
+ const sPct = srl?.used_percent != null ? Math.round(srl.used_percent) : null;
88
+ const sBarColor =
89
+ sPct == null ? "bg-indigo-500" : sPct >= 90 ? "bg-red-500" : sPct >= 60 ? "bg-amber-500" : "bg-indigo-500";
90
+ const sPctColor =
91
+ sPct == null
92
+ ? "text-indigo-500"
93
+ : sPct >= 90
94
+ ? "text-red-500"
95
+ : sPct >= 60
96
+ ? "text-amber-600 dark:text-amber-500"
97
+ : "text-indigo-500";
98
+ const sResetAt = srl?.reset_at ? formatResetTime(srl.reset_at, lang === "zh") : null;
99
+ const sWindowSec = srl?.limit_window_seconds;
100
+ const sWindowDur = sWindowSec ? formatWindowDuration(sWindowSec, lang === "zh") : null;
101
+
102
  return (
103
  <div class="bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl p-4 shadow-sm hover:shadow-md transition-all hover:border-primary/30 dark:hover:border-primary/50">
104
  {/* Header */}
 
175
  </div>
176
  )}
177
 
178
+ {/* Quota bars */}
179
+ {(rl || srl) && (
180
+ <div class="pt-3 mt-3 border-t border-slate-100 dark:border-border-dark space-y-3">
181
+ {/* Primary window */}
182
+ {rl && (
183
+ <div>
184
+ <div class="flex justify-between text-[0.78rem] mb-1.5">
185
+ <span class="text-slate-500 dark:text-text-dim">
186
+ {t("rateLimit")}
187
+ {windowDur && (
188
+ <span class="ml-1 text-slate-400 dark:text-text-dim/70 text-[0.65rem]">({windowDur})</span>
189
+ )}
190
+ </span>
191
+ {rl.limit_reached ? (
192
+ <span class="px-2 py-0.5 rounded-full bg-red-100 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-xs font-medium">
193
+ {t("limitReached")}
194
+ </span>
195
+ ) : pct != null ? (
196
+ <span class={`font-medium ${pctColor}`}>
197
+ {pct}% {t("used")}
198
+ </span>
199
+ ) : (
200
+ <span class="font-medium text-primary">{t("ok")}</span>
201
+ )}
202
+ </div>
203
+ {pct != null && (
204
+ <div class="w-full bg-slate-100 dark:bg-border-dark rounded-full h-2 overflow-hidden">
205
+ <div class={`${barColor} h-2 rounded-full transition-all`} style={{ width: `${pct}%` }} />
206
+ </div>
207
+ )}
208
+ {resetAt && (
209
+ <p class="text-xs text-slate-400 dark:text-text-dim mt-1">
210
+ {t("resetsAt")} {resetAt}
211
+ </p>
212
+ )}
213
  </div>
214
  )}
215
+
216
+ {/* Secondary window (e.g. weekly) */}
217
+ {srl && (
218
+ <div>
219
+ <div class="flex justify-between text-[0.78rem] mb-1.5">
220
+ <span class="text-slate-500 dark:text-text-dim">
221
+ {t("secondaryRateLimit")}
222
+ {sWindowDur && (
223
+ <span class="ml-1 text-slate-400 dark:text-text-dim/70 text-[0.65rem]">({sWindowDur})</span>
224
+ )}
225
  </span>
226
+ {srl.limit_reached ? (
227
+ <span class="px-2 py-0.5 rounded-full bg-red-100 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-xs font-medium">
228
+ {t("limitReached")}
229
+ </span>
230
+ ) : sPct != null ? (
231
+ <span class={`font-medium ${sPctColor}`}>
232
+ {sPct}% {t("used")}
233
+ </span>
234
+ ) : (
235
+ <span class="font-medium text-indigo-500">{t("ok")}</span>
236
+ )}
237
+ </div>
238
+ {sPct != null && (
239
+ <div class="w-full bg-slate-100 dark:bg-border-dark rounded-full h-2 overflow-hidden">
240
+ <div class={`${sBarColor} h-2 rounded-full transition-all`} style={{ width: `${sPct}%` }} />
241
+ </div>
242
  )}
243
+ {sResetAt && (
244
+ <p class="text-xs text-slate-400 dark:text-text-dim mt-1">
245
+ {t("resetsAt")} {sResetAt}
246
+ </p>
247
+ )}
248
+ </div>
249
  )}
250
  </div>
251
  )}
web/src/hooks/use-accounts.ts CHANGED
@@ -1,12 +1,16 @@
1
  import { useState, useEffect, useCallback } from "preact/hooks";
2
 
 
 
 
 
 
 
 
3
  export interface AccountQuota {
4
- rate_limit?: {
5
- used_percent?: number | null;
6
- limit_reached?: boolean;
7
- reset_at?: number | null;
8
- limit_window_seconds?: number | null;
9
- };
10
  }
11
 
12
  export interface Account {
 
1
  import { useState, useEffect, useCallback } from "preact/hooks";
2
 
3
+ export interface AccountQuotaWindow {
4
+ used_percent?: number | null;
5
+ limit_reached?: boolean;
6
+ reset_at?: number | null;
7
+ limit_window_seconds?: number | null;
8
+ }
9
+
10
  export interface AccountQuota {
11
+ rate_limit?: AccountQuotaWindow;
12
+ /** Secondary rate limit window (e.g. weekly cap). */
13
+ secondary_rate_limit?: AccountQuotaWindow | null;
 
 
 
14
  }
15
 
16
  export interface Account {