github-issue-tower-defence-management 1.58.2 → 1.59.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/.gitattributes +2 -0
  2. package/.prettierignore +1 -0
  3. package/CHANGELOG.md +21 -0
  4. package/README.md +3 -3
  5. package/bin/adapter/entry-points/cli/index.js +1 -3
  6. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  7. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +8 -4
  8. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  9. package/bin/adapter/proxy/RateLimitCache.js +104 -12
  10. package/bin/adapter/proxy/RateLimitCache.js.map +1 -1
  11. package/bin/adapter/proxy/proxyEntry.js +19 -0
  12. package/bin/adapter/proxy/proxyEntry.js.map +1 -1
  13. package/bin/adapter/repositories/ProxyClaudeTokenUsageRepository.js +33 -2
  14. package/bin/adapter/repositories/ProxyClaudeTokenUsageRepository.js.map +1 -1
  15. package/bin/adapter/repositories/ProxyRateLimitCacheRepository.js +92 -0
  16. package/bin/adapter/repositories/ProxyRateLimitCacheRepository.js.map +1 -0
  17. package/bin/domain/usecases/HandleScheduledEventUseCase.js +7 -1
  18. package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
  19. package/bin/domain/usecases/StartPreparationUseCase.js +34 -35
  20. package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -1
  21. package/bin/domain/usecases/UpdateRateLimitCacheUseCase.js +18 -0
  22. package/bin/domain/usecases/UpdateRateLimitCacheUseCase.js.map +1 -0
  23. package/bin/domain/usecases/adapter-interfaces/RateLimitCacheRepository.js +3 -0
  24. package/bin/domain/usecases/adapter-interfaces/RateLimitCacheRepository.js.map +1 -0
  25. package/package.json +1 -1
  26. package/src/adapter/entry-points/cli/index.ts +0 -3
  27. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.test.ts +6 -0
  28. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +9 -3
  29. package/src/adapter/proxy/RateLimitCache.test.ts +250 -0
  30. package/src/adapter/proxy/RateLimitCache.ts +114 -25
  31. package/src/adapter/proxy/proxyEntry.ts +24 -1
  32. package/src/adapter/repositories/ProxyClaudeTokenUsageRepository.test.ts +354 -7
  33. package/src/adapter/repositories/ProxyClaudeTokenUsageRepository.ts +42 -2
  34. package/src/adapter/repositories/ProxyRateLimitCacheRepository.test.ts +101 -0
  35. package/src/adapter/repositories/ProxyRateLimitCacheRepository.ts +66 -0
  36. package/src/domain/entities/ClaudeTokenUsage.ts +7 -0
  37. package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +63 -0
  38. package/src/domain/usecases/HandleScheduledEventUseCase.ts +7 -0
  39. package/src/domain/usecases/StartPreparationUseCase.test.ts +1212 -887
  40. package/src/domain/usecases/StartPreparationUseCase.ts +47 -57
  41. package/src/domain/usecases/UpdateRateLimitCacheUseCase.test.ts +81 -0
  42. package/src/domain/usecases/UpdateRateLimitCacheUseCase.ts +16 -0
  43. package/src/domain/usecases/adapter-interfaces/RateLimitCacheRepository.ts +9 -0
  44. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  45. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  46. package/types/adapter/proxy/RateLimitCache.d.ts +11 -0
  47. package/types/adapter/proxy/RateLimitCache.d.ts.map +1 -1
  48. package/types/adapter/proxy/proxyEntry.d.ts.map +1 -1
  49. package/types/adapter/repositories/ProxyClaudeTokenUsageRepository.d.ts.map +1 -1
  50. package/types/adapter/repositories/ProxyRateLimitCacheRepository.d.ts +10 -0
  51. package/types/adapter/repositories/ProxyRateLimitCacheRepository.d.ts.map +1 -0
  52. package/types/domain/entities/ClaudeTokenUsage.d.ts +6 -0
  53. package/types/domain/entities/ClaudeTokenUsage.d.ts.map +1 -1
  54. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +3 -1
  55. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
  56. package/types/domain/usecases/StartPreparationUseCase.d.ts +3 -3
  57. package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -1
  58. package/types/domain/usecases/UpdateRateLimitCacheUseCase.d.ts +9 -0
  59. package/types/domain/usecases/UpdateRateLimitCacheUseCase.d.ts.map +1 -0
  60. package/types/domain/usecases/adapter-interfaces/RateLimitCacheRepository.d.ts +9 -0
  61. package/types/domain/usecases/adapter-interfaces/RateLimitCacheRepository.d.ts.map +1 -0
@@ -3,12 +3,22 @@ import * as fs from 'fs';
3
3
  import * as os from 'os';
4
4
  import * as path from 'path';
5
5
 
6
+ export interface ModelWeeklyLimit {
7
+ rejected: boolean;
8
+ resetsAt: number;
9
+ }
10
+
6
11
  export interface RateLimitSnapshot {
7
12
  fiveHourUtilization: number;
8
13
  fiveHourReset: number;
9
14
  sevenDayUtilization: number;
10
15
  sevenDayReset: number;
11
16
  blocked: boolean;
17
+ rejected: boolean;
18
+ unifiedRejected: boolean;
19
+ fiveHourRejected: boolean;
20
+ sevenDayRejected: boolean;
21
+ modelWeeklyLimits: Record<string, ModelWeeklyLimit>;
12
22
  }
13
23
 
14
24
  export const PROXY_PORT = 8787;
@@ -26,6 +36,37 @@ export const hashToken = (token: string): string =>
26
36
  export const cachePathForToken = (token: string): string =>
27
37
  path.join(cacheDir(), `${hashToken(token)}.json`);
28
38
 
39
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
40
+ value !== null && typeof value === 'object' && !Array.isArray(value);
41
+
42
+ const readPayload = (filePath: string): Record<string, unknown> => {
43
+ if (!fs.existsSync(filePath)) return {};
44
+ try {
45
+ const raw = fs.readFileSync(filePath, 'utf8');
46
+ const parsed: unknown = JSON.parse(raw);
47
+ return isRecord(parsed) ? parsed : {};
48
+ } catch {
49
+ return {};
50
+ }
51
+ };
52
+
53
+ const readModelWeeklyLimits = (
54
+ payload: Record<string, unknown>,
55
+ ): Record<string, ModelWeeklyLimit> => {
56
+ const stored = payload.modelWeeklyLimits;
57
+ const result: Record<string, ModelWeeklyLimit> = {};
58
+ if (!isRecord(stored)) return result;
59
+ for (const [limitType, value] of Object.entries(stored)) {
60
+ if (!isRecord(value)) continue;
61
+ const rejected = value.rejected;
62
+ const resetsAt = value.resetsAt;
63
+ if (typeof rejected === 'boolean' && typeof resetsAt === 'number') {
64
+ result[limitType] = { rejected, resetsAt };
65
+ }
66
+ }
67
+ return result;
68
+ };
69
+
29
70
  export const writeRateLimit = (
30
71
  token: string,
31
72
  headers: Record<string, string | string[] | undefined>,
@@ -39,44 +80,84 @@ export const writeRateLimit = (
39
80
  if (Array.isArray(value)) return value[0];
40
81
  return value;
41
82
  };
83
+ const filePath = path.join(dir, `${hashToken(token)}.json`);
84
+ const existing = readPayload(filePath);
85
+ const rateLimitHeaders: Record<string, string> = {};
86
+ for (const key of Object.keys(headers)) {
87
+ if (key.startsWith('anthropic-ratelimit-')) {
88
+ const value = pick(key);
89
+ if (value !== undefined) {
90
+ rateLimitHeaders[key] = value;
91
+ }
92
+ }
93
+ }
42
94
  const payload = {
43
95
  ts: Date.now() / 1000,
44
- headers: {
45
- 'anthropic-ratelimit-unified-status': pick(
46
- 'anthropic-ratelimit-unified-status',
47
- ),
48
- 'anthropic-ratelimit-unified-5h-status': pick(
49
- 'anthropic-ratelimit-unified-5h-status',
50
- ),
51
- 'anthropic-ratelimit-unified-5h-reset': pick(
52
- 'anthropic-ratelimit-unified-5h-reset',
53
- ),
54
- 'anthropic-ratelimit-unified-5h-utilization': pick(
55
- 'anthropic-ratelimit-unified-5h-utilization',
56
- ),
57
- 'anthropic-ratelimit-unified-7d-status': pick(
58
- 'anthropic-ratelimit-unified-7d-status',
59
- ),
60
- 'anthropic-ratelimit-unified-7d-reset': pick(
61
- 'anthropic-ratelimit-unified-7d-reset',
62
- ),
63
- 'anthropic-ratelimit-unified-7d-utilization': pick(
64
- 'anthropic-ratelimit-unified-7d-utilization',
65
- ),
66
- },
96
+ headers: rateLimitHeaders,
97
+ modelWeeklyLimits: readModelWeeklyLimits(existing),
67
98
  };
99
+ fs.writeFileSync(filePath, JSON.stringify(payload));
100
+ };
101
+
102
+ export const writeModelRateLimit = (
103
+ token: string,
104
+ limits: Record<string, ModelWeeklyLimit>,
105
+ ): void => {
106
+ const limitTypes = Object.keys(limits);
107
+ if (limitTypes.length === 0) return;
108
+ const dir = cacheDir();
109
+ if (!fs.existsSync(dir)) {
110
+ fs.mkdirSync(dir, { recursive: true });
111
+ }
68
112
  const filePath = path.join(dir, `${hashToken(token)}.json`);
113
+ const existing = readPayload(filePath);
114
+ const merged = {
115
+ ...readModelWeeklyLimits(existing),
116
+ ...limits,
117
+ };
118
+ const payload = {
119
+ ...existing,
120
+ modelWeeklyLimits: merged,
121
+ };
69
122
  fs.writeFileSync(filePath, JSON.stringify(payload));
70
123
  };
71
124
 
125
+ export const parseModelRateLimitsFromBody = (
126
+ body: string,
127
+ ): Record<string, ModelWeeklyLimit> => {
128
+ const result: Record<string, ModelWeeklyLimit> = {};
129
+ const matches = body.match(
130
+ /\{[^{}]*"rateLimitType"[^{}]*\}|\{[^{}]*"resetsAt"[^{}]*"rateLimitType"[^{}]*\}/g,
131
+ );
132
+ if (matches === null) return result;
133
+ for (const candidate of matches) {
134
+ let parsed: unknown;
135
+ try {
136
+ parsed = JSON.parse(candidate);
137
+ } catch {
138
+ continue;
139
+ }
140
+ if (!isRecord(parsed)) continue;
141
+ const rateLimitType = parsed.rateLimitType;
142
+ const status = parsed.status;
143
+ const resetsAt = parsed.resetsAt;
144
+ if (typeof rateLimitType !== 'string') continue;
145
+ if (typeof status !== 'string') continue;
146
+ if (typeof resetsAt !== 'number') continue;
147
+ result[rateLimitType] = {
148
+ rejected: status === 'rejected',
149
+ resetsAt,
150
+ };
151
+ }
152
+ return result;
153
+ };
154
+
72
155
  export const readRateLimit = (token: string): RateLimitSnapshot | null => {
73
156
  const filePath = cachePathForToken(token);
74
157
  if (!fs.existsSync(filePath)) return null;
75
158
  try {
76
159
  const raw = fs.readFileSync(filePath, 'utf8');
77
160
  const parsed: unknown = JSON.parse(raw);
78
- const isRecord = (value: unknown): value is Record<string, unknown> =>
79
- value !== null && typeof value === 'object' && !Array.isArray(value);
80
161
  if (!isRecord(parsed)) return null;
81
162
  const headersUnknown = parsed.headers;
82
163
  const headers: Record<string, string> = {};
@@ -96,6 +177,9 @@ export const readRateLimit = (token: string): RateLimitSnapshot | null => {
96
177
  const status = headers['anthropic-ratelimit-unified-status'];
97
178
  const fiveHourStatus = headers['anthropic-ratelimit-unified-5h-status'];
98
179
  const sevenDayStatus = headers['anthropic-ratelimit-unified-7d-status'];
180
+ const unifiedRejected = status === 'rejected';
181
+ const fiveHourRejected = fiveHourStatus === 'rejected';
182
+ const sevenDayRejected = sevenDayStatus === 'rejected';
99
183
  return {
100
184
  fiveHourUtilization: num('anthropic-ratelimit-unified-5h-utilization'),
101
185
  fiveHourReset: num('anthropic-ratelimit-unified-5h-reset'),
@@ -105,6 +189,11 @@ export const readRateLimit = (token: string): RateLimitSnapshot | null => {
105
189
  status === 'blocked' ||
106
190
  fiveHourStatus === 'blocked' ||
107
191
  sevenDayStatus === 'blocked',
192
+ rejected: unifiedRejected || fiveHourRejected || sevenDayRejected,
193
+ unifiedRejected,
194
+ fiveHourRejected,
195
+ sevenDayRejected,
196
+ modelWeeklyLimits: readModelWeeklyLimits(parsed),
108
197
  };
109
198
  } catch {
110
199
  return null;
@@ -1,9 +1,16 @@
1
1
  import * as http from 'http';
2
2
  import * as https from 'https';
3
- import { PROXY_PORT, writeRateLimit } from './RateLimitCache';
3
+ import {
4
+ PROXY_PORT,
5
+ parseModelRateLimitsFromBody,
6
+ writeModelRateLimit,
7
+ writeRateLimit,
8
+ } from './RateLimitCache';
4
9
 
5
10
  const UPSTREAM_HOST = 'api.anthropic.com';
6
11
 
12
+ const MAX_INSPECTED_BODY_BYTES = 1024 * 1024;
13
+
7
14
  const BEARER_PREFIX = 'bearer ';
8
15
 
9
16
  const extractToken = (
@@ -40,6 +47,22 @@ const startProxy = (port: number): void => {
40
47
  } catch (error) {
41
48
  console.error('Failed to write rate limit cache:', error);
42
49
  }
50
+ const inspectedChunks: Uint8Array[] = [];
51
+ let inspectedBytes = 0;
52
+ upstreamResponse.on('data', (chunk: Buffer) => {
53
+ if (inspectedBytes >= MAX_INSPECTED_BODY_BYTES) return;
54
+ inspectedChunks.push(new Uint8Array(chunk));
55
+ inspectedBytes += chunk.length;
56
+ });
57
+ upstreamResponse.on('end', () => {
58
+ try {
59
+ const body = Buffer.concat(inspectedChunks).toString('utf8');
60
+ const limits = parseModelRateLimitsFromBody(body);
61
+ writeModelRateLimit(token, limits);
62
+ } catch (error) {
63
+ console.error('Failed to write model rate limit cache:', error);
64
+ }
65
+ });
43
66
  }
44
67
  clientResponse.writeHead(
45
68
  upstreamResponse.statusCode ?? 502,
@@ -65,16 +65,24 @@ describe('ProxyClaudeTokenUsageRepository', () => {
65
65
  expect(mockLoadTokens.mock.calls).toEqual([['/tokens.json']]);
66
66
  });
67
67
 
68
+ const futureReset = Math.floor(Date.now() / 1000) + 3600;
69
+ const pastReset = Math.floor(Date.now() / 1000) - 3600;
70
+
68
71
  it('should map each token to its cached utilization', async () => {
69
72
  mockLoadTokens.mockReturnValue(['token-a', 'token-b']);
70
73
  mockReadRateLimit.mockImplementation((token: string) => {
71
74
  if (token === 'token-a') {
72
75
  return {
73
76
  fiveHourUtilization: 42,
74
- fiveHourReset: 0,
77
+ fiveHourReset: futureReset,
75
78
  sevenDayUtilization: 0,
76
- sevenDayReset: 0,
79
+ sevenDayReset: futureReset,
77
80
  blocked: false,
81
+ rejected: false,
82
+ unifiedRejected: false,
83
+ fiveHourRejected: false,
84
+ sevenDayRejected: false,
85
+ modelWeeklyLimits: {},
78
86
  };
79
87
  }
80
88
  return null;
@@ -84,8 +92,20 @@ describe('ProxyClaudeTokenUsageRepository', () => {
84
92
  const result = await repository.getAvailableTokenUsages();
85
93
 
86
94
  expect(result).toEqual([
87
- { token: 'token-a', fiveHourUtilization: 42, blocked: false },
88
- { token: 'token-b', fiveHourUtilization: 0, blocked: false },
95
+ {
96
+ token: 'token-a',
97
+ fiveHourUtilization: 42,
98
+ blocked: false,
99
+ rejected: false,
100
+ modelWeeklyLimits: {},
101
+ },
102
+ {
103
+ token: 'token-b',
104
+ fiveHourUtilization: 0,
105
+ blocked: false,
106
+ rejected: false,
107
+ modelWeeklyLimits: {},
108
+ },
89
109
  ]);
90
110
  });
91
111
 
@@ -93,17 +113,344 @@ describe('ProxyClaudeTokenUsageRepository', () => {
93
113
  mockLoadTokens.mockReturnValue(['token-a']);
94
114
  mockReadRateLimit.mockReturnValue({
95
115
  fiveHourUtilization: 5,
96
- fiveHourReset: 0,
116
+ fiveHourReset: futureReset,
97
117
  sevenDayUtilization: 0,
98
- sevenDayReset: 0,
118
+ sevenDayReset: futureReset,
99
119
  blocked: true,
120
+ rejected: false,
121
+ unifiedRejected: false,
122
+ fiveHourRejected: false,
123
+ sevenDayRejected: false,
124
+ modelWeeklyLimits: {},
125
+ });
126
+ const repository = new ProxyClaudeTokenUsageRepository('/tokens.json');
127
+
128
+ const result = await repository.getAvailableTokenUsages();
129
+
130
+ expect(result).toEqual([
131
+ {
132
+ token: 'token-a',
133
+ fiveHourUtilization: 5,
134
+ blocked: true,
135
+ rejected: false,
136
+ modelWeeklyLimits: {},
137
+ },
138
+ ]);
139
+ });
140
+
141
+ it('should propagate the rejected status from the cache', async () => {
142
+ mockLoadTokens.mockReturnValue(['token-a']);
143
+ mockReadRateLimit.mockReturnValue({
144
+ fiveHourUtilization: 100,
145
+ fiveHourReset: futureReset,
146
+ sevenDayUtilization: 0,
147
+ sevenDayReset: futureReset,
148
+ blocked: false,
149
+ rejected: true,
150
+ unifiedRejected: false,
151
+ fiveHourRejected: true,
152
+ sevenDayRejected: false,
153
+ modelWeeklyLimits: {},
154
+ });
155
+ const repository = new ProxyClaudeTokenUsageRepository('/tokens.json');
156
+
157
+ const result = await repository.getAvailableTokenUsages();
158
+
159
+ expect(result).toEqual([
160
+ {
161
+ token: 'token-a',
162
+ fiveHourUtilization: 100,
163
+ blocked: false,
164
+ rejected: true,
165
+ modelWeeklyLimits: {},
166
+ },
167
+ ]);
168
+ });
169
+
170
+ it('should normalize fiveHourUtilization to 0 when the 5h reset has passed', async () => {
171
+ mockLoadTokens.mockReturnValue(['token-a']);
172
+ mockReadRateLimit.mockReturnValue({
173
+ fiveHourUtilization: 100,
174
+ fiveHourReset: pastReset,
175
+ sevenDayUtilization: 30,
176
+ sevenDayReset: futureReset,
177
+ blocked: false,
178
+ rejected: false,
179
+ unifiedRejected: false,
180
+ fiveHourRejected: false,
181
+ sevenDayRejected: false,
182
+ modelWeeklyLimits: {},
183
+ });
184
+ const repository = new ProxyClaudeTokenUsageRepository('/tokens.json');
185
+
186
+ const result = await repository.getAvailableTokenUsages();
187
+
188
+ expect(result).toEqual([
189
+ {
190
+ token: 'token-a',
191
+ fiveHourUtilization: 0,
192
+ blocked: false,
193
+ rejected: false,
194
+ modelWeeklyLimits: {},
195
+ },
196
+ ]);
197
+ });
198
+
199
+ it('should keep fiveHourUtilization when the 5h reset is in the future', async () => {
200
+ mockLoadTokens.mockReturnValue(['token-a']);
201
+ mockReadRateLimit.mockReturnValue({
202
+ fiveHourUtilization: 95,
203
+ fiveHourReset: futureReset,
204
+ sevenDayUtilization: 0,
205
+ sevenDayReset: futureReset,
206
+ blocked: false,
207
+ rejected: false,
208
+ unifiedRejected: false,
209
+ fiveHourRejected: false,
210
+ sevenDayRejected: false,
211
+ modelWeeklyLimits: {},
212
+ });
213
+ const repository = new ProxyClaudeTokenUsageRepository('/tokens.json');
214
+
215
+ const result = await repository.getAvailableTokenUsages();
216
+
217
+ expect(result).toEqual([
218
+ {
219
+ token: 'token-a',
220
+ fiveHourUtilization: 95,
221
+ blocked: false,
222
+ rejected: false,
223
+ modelWeeklyLimits: {},
224
+ },
225
+ ]);
226
+ });
227
+
228
+ it('should clear a 5h-origin rejection once the 5h reset has passed', async () => {
229
+ mockLoadTokens.mockReturnValue(['token-a']);
230
+ mockReadRateLimit.mockReturnValue({
231
+ fiveHourUtilization: 100,
232
+ fiveHourReset: pastReset,
233
+ sevenDayUtilization: 0,
234
+ sevenDayReset: futureReset,
235
+ blocked: false,
236
+ rejected: true,
237
+ unifiedRejected: false,
238
+ fiveHourRejected: true,
239
+ sevenDayRejected: false,
240
+ modelWeeklyLimits: {},
241
+ });
242
+ const repository = new ProxyClaudeTokenUsageRepository('/tokens.json');
243
+
244
+ const result = await repository.getAvailableTokenUsages();
245
+
246
+ expect(result).toEqual([
247
+ {
248
+ token: 'token-a',
249
+ fiveHourUtilization: 0,
250
+ blocked: false,
251
+ rejected: false,
252
+ modelWeeklyLimits: {},
253
+ },
254
+ ]);
255
+ });
256
+
257
+ it('should clear a 7d-origin rejection once the 7d reset has passed', async () => {
258
+ mockLoadTokens.mockReturnValue(['token-a']);
259
+ mockReadRateLimit.mockReturnValue({
260
+ fiveHourUtilization: 10,
261
+ fiveHourReset: futureReset,
262
+ sevenDayUtilization: 100,
263
+ sevenDayReset: pastReset,
264
+ blocked: false,
265
+ rejected: true,
266
+ unifiedRejected: false,
267
+ fiveHourRejected: false,
268
+ sevenDayRejected: true,
269
+ modelWeeklyLimits: {},
270
+ });
271
+ const repository = new ProxyClaudeTokenUsageRepository('/tokens.json');
272
+
273
+ const result = await repository.getAvailableTokenUsages();
274
+
275
+ expect(result).toEqual([
276
+ {
277
+ token: 'token-a',
278
+ fiveHourUtilization: 10,
279
+ blocked: false,
280
+ rejected: false,
281
+ modelWeeklyLimits: {},
282
+ },
283
+ ]);
284
+ });
285
+
286
+ it('should keep a 5h-origin rejection while the 5h reset is in the future', async () => {
287
+ mockLoadTokens.mockReturnValue(['token-a']);
288
+ mockReadRateLimit.mockReturnValue({
289
+ fiveHourUtilization: 100,
290
+ fiveHourReset: futureReset,
291
+ sevenDayUtilization: 0,
292
+ sevenDayReset: futureReset,
293
+ blocked: false,
294
+ rejected: true,
295
+ unifiedRejected: false,
296
+ fiveHourRejected: true,
297
+ sevenDayRejected: false,
298
+ modelWeeklyLimits: {},
299
+ });
300
+ const repository = new ProxyClaudeTokenUsageRepository('/tokens.json');
301
+
302
+ const result = await repository.getAvailableTokenUsages();
303
+
304
+ expect(result).toEqual([
305
+ {
306
+ token: 'token-a',
307
+ fiveHourUtilization: 100,
308
+ blocked: false,
309
+ rejected: true,
310
+ modelWeeklyLimits: {},
311
+ },
312
+ ]);
313
+ });
314
+
315
+ it('should keep a still-active 7d rejection after the 5h reset has passed', async () => {
316
+ mockLoadTokens.mockReturnValue(['token-a']);
317
+ mockReadRateLimit.mockReturnValue({
318
+ fiveHourUtilization: 100,
319
+ fiveHourReset: pastReset,
320
+ sevenDayUtilization: 100,
321
+ sevenDayReset: futureReset,
322
+ blocked: false,
323
+ rejected: true,
324
+ unifiedRejected: false,
325
+ fiveHourRejected: false,
326
+ sevenDayRejected: true,
327
+ modelWeeklyLimits: {},
328
+ });
329
+ const repository = new ProxyClaudeTokenUsageRepository('/tokens.json');
330
+
331
+ const result = await repository.getAvailableTokenUsages();
332
+
333
+ expect(result).toEqual([
334
+ {
335
+ token: 'token-a',
336
+ fiveHourUtilization: 0,
337
+ blocked: false,
338
+ rejected: true,
339
+ modelWeeklyLimits: {},
340
+ },
341
+ ]);
342
+ });
343
+
344
+ it('should clear a unified rejection once the 5h reset has passed', async () => {
345
+ mockLoadTokens.mockReturnValue(['token-a']);
346
+ mockReadRateLimit.mockReturnValue({
347
+ fiveHourUtilization: 100,
348
+ fiveHourReset: pastReset,
349
+ sevenDayUtilization: 0,
350
+ sevenDayReset: futureReset,
351
+ blocked: false,
352
+ rejected: true,
353
+ unifiedRejected: true,
354
+ fiveHourRejected: false,
355
+ sevenDayRejected: false,
356
+ modelWeeklyLimits: {},
357
+ });
358
+ const repository = new ProxyClaudeTokenUsageRepository('/tokens.json');
359
+
360
+ const result = await repository.getAvailableTokenUsages();
361
+
362
+ expect(result).toEqual([
363
+ {
364
+ token: 'token-a',
365
+ fiveHourUtilization: 0,
366
+ blocked: false,
367
+ rejected: false,
368
+ modelWeeklyLimits: {},
369
+ },
370
+ ]);
371
+ });
372
+
373
+ it('should default rejected to false when no snapshot exists', async () => {
374
+ mockLoadTokens.mockReturnValue(['token-a']);
375
+ mockReadRateLimit.mockReturnValue(null);
376
+ const repository = new ProxyClaudeTokenUsageRepository('/tokens.json');
377
+
378
+ const result = await repository.getAvailableTokenUsages();
379
+
380
+ expect(result).toEqual([
381
+ {
382
+ token: 'token-a',
383
+ fiveHourUtilization: 0,
384
+ blocked: false,
385
+ rejected: false,
386
+ modelWeeklyLimits: {},
387
+ },
388
+ ]);
389
+ });
390
+
391
+ it('should keep a model weekly rejection while its reset is in the future', async () => {
392
+ mockLoadTokens.mockReturnValue(['token-a']);
393
+ mockReadRateLimit.mockReturnValue({
394
+ fiveHourUtilization: 5,
395
+ fiveHourReset: futureReset,
396
+ sevenDayUtilization: 10,
397
+ sevenDayReset: futureReset,
398
+ blocked: false,
399
+ rejected: false,
400
+ unifiedRejected: false,
401
+ fiveHourRejected: false,
402
+ sevenDayRejected: false,
403
+ modelWeeklyLimits: {
404
+ seven_day_sonnet: { rejected: true, resetsAt: futureReset },
405
+ },
406
+ });
407
+ const repository = new ProxyClaudeTokenUsageRepository('/tokens.json');
408
+
409
+ const result = await repository.getAvailableTokenUsages();
410
+
411
+ expect(result).toEqual([
412
+ {
413
+ token: 'token-a',
414
+ fiveHourUtilization: 5,
415
+ blocked: false,
416
+ rejected: false,
417
+ modelWeeklyLimits: {
418
+ seven_day_sonnet: { rejected: true, resetsAt: futureReset },
419
+ },
420
+ },
421
+ ]);
422
+ });
423
+
424
+ it('should clear a model weekly rejection once its reset has passed', async () => {
425
+ mockLoadTokens.mockReturnValue(['token-a']);
426
+ mockReadRateLimit.mockReturnValue({
427
+ fiveHourUtilization: 5,
428
+ fiveHourReset: futureReset,
429
+ sevenDayUtilization: 10,
430
+ sevenDayReset: futureReset,
431
+ blocked: false,
432
+ rejected: false,
433
+ unifiedRejected: false,
434
+ fiveHourRejected: false,
435
+ sevenDayRejected: false,
436
+ modelWeeklyLimits: {
437
+ seven_day_sonnet: { rejected: true, resetsAt: pastReset },
438
+ },
100
439
  });
101
440
  const repository = new ProxyClaudeTokenUsageRepository('/tokens.json');
102
441
 
103
442
  const result = await repository.getAvailableTokenUsages();
104
443
 
105
444
  expect(result).toEqual([
106
- { token: 'token-a', fiveHourUtilization: 5, blocked: true },
445
+ {
446
+ token: 'token-a',
447
+ fiveHourUtilization: 5,
448
+ blocked: false,
449
+ rejected: false,
450
+ modelWeeklyLimits: {
451
+ seven_day_sonnet: { rejected: false, resetsAt: pastReset },
452
+ },
453
+ },
107
454
  ]);
108
455
  });
109
456
  });
@@ -22,12 +22,52 @@ export class ProxyClaudeTokenUsageRepository implements ClaudeTokenUsageReposito
22
22
  if (tokens === null) {
23
23
  return [];
24
24
  }
25
+ const nowEpochSeconds = Date.now() / 1000;
25
26
  return tokens.map((token) => {
26
27
  const snapshot = readRateLimit(token);
28
+ if (snapshot === null) {
29
+ return {
30
+ token,
31
+ fiveHourUtilization: 0,
32
+ blocked: false,
33
+ rejected: false,
34
+ modelWeeklyLimits: {},
35
+ };
36
+ }
37
+ const fiveHourExpired = nowEpochSeconds > snapshot.fiveHourReset;
38
+ const sevenDayExpired = nowEpochSeconds > snapshot.sevenDayReset;
39
+ const fiveHourUtilization = fiveHourExpired
40
+ ? 0
41
+ : snapshot.fiveHourUtilization;
42
+ const fiveHourRejectionActive =
43
+ snapshot.fiveHourRejected && !fiveHourExpired;
44
+ const sevenDayRejectionActive =
45
+ snapshot.sevenDayRejected && !sevenDayExpired;
46
+ const unifiedRejectionActive =
47
+ snapshot.unifiedRejected && !fiveHourExpired;
48
+ const rejected =
49
+ unifiedRejectionActive ||
50
+ fiveHourRejectionActive ||
51
+ sevenDayRejectionActive;
52
+ const modelWeeklyLimits: Record<
53
+ string,
54
+ { rejected: boolean; resetsAt: number }
55
+ > = {};
56
+ for (const [limitType, limit] of Object.entries(
57
+ snapshot.modelWeeklyLimits,
58
+ )) {
59
+ const expired = nowEpochSeconds > limit.resetsAt;
60
+ modelWeeklyLimits[limitType] = {
61
+ rejected: limit.rejected && !expired,
62
+ resetsAt: limit.resetsAt,
63
+ };
64
+ }
27
65
  return {
28
66
  token,
29
- fiveHourUtilization: snapshot ? snapshot.fiveHourUtilization : 0,
30
- blocked: snapshot?.blocked ?? false,
67
+ fiveHourUtilization,
68
+ blocked: snapshot.blocked,
69
+ rejected,
70
+ modelWeeklyLimits,
31
71
  };
32
72
  });
33
73
  };