github-issue-tower-defence-management 1.58.3 → 1.60.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 (67) hide show
  1. package/.gitattributes +2 -0
  2. package/.github/workflows/create-pr.yml +1 -1
  3. package/.github/workflows/test.yml +4 -0
  4. package/.github/workflows/umino-project.yml +3 -3
  5. package/.prettierignore +1 -0
  6. package/CHANGELOG.md +19 -0
  7. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +7 -1
  8. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  9. package/bin/adapter/proxy/ClaudeMessageResponseParser.js +123 -0
  10. package/bin/adapter/proxy/ClaudeMessageResponseParser.js.map +1 -0
  11. package/bin/adapter/proxy/RateLimitCache.js +10 -9
  12. package/bin/adapter/proxy/RateLimitCache.js.map +1 -1
  13. package/bin/adapter/proxy/proxyEntry.js +16 -3
  14. package/bin/adapter/proxy/proxyEntry.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/adapter/repositories/SqliteClaudeMessageResponseRepository.js +186 -0
  18. package/bin/adapter/repositories/SqliteClaudeMessageResponseRepository.js.map +1 -0
  19. package/bin/domain/entities/ClaudeMessageResponse.js +3 -0
  20. package/bin/domain/entities/ClaudeMessageResponse.js.map +1 -0
  21. package/bin/domain/usecases/HandleScheduledEventUseCase.js +7 -1
  22. package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
  23. package/bin/domain/usecases/UpdateRateLimitCacheUseCase.js +18 -0
  24. package/bin/domain/usecases/UpdateRateLimitCacheUseCase.js.map +1 -0
  25. package/bin/domain/usecases/adapter-interfaces/ClaudeMessageResponseRepository.js +3 -0
  26. package/bin/domain/usecases/adapter-interfaces/ClaudeMessageResponseRepository.js.map +1 -0
  27. package/bin/domain/usecases/adapter-interfaces/RateLimitCacheRepository.js +3 -0
  28. package/bin/domain/usecases/adapter-interfaces/RateLimitCacheRepository.js.map +1 -0
  29. package/package.json +3 -1
  30. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.test.ts +6 -0
  31. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +9 -0
  32. package/src/adapter/proxy/ClaudeMessageResponseParser.test.ts +211 -0
  33. package/src/adapter/proxy/ClaudeMessageResponseParser.ts +180 -0
  34. package/src/adapter/proxy/RateLimitCache.test.ts +60 -0
  35. package/src/adapter/proxy/RateLimitCache.ts +10 -23
  36. package/src/adapter/proxy/proxyEntry.ts +28 -3
  37. package/src/adapter/repositories/ProxyRateLimitCacheRepository.test.ts +101 -0
  38. package/src/adapter/repositories/ProxyRateLimitCacheRepository.ts +66 -0
  39. package/src/adapter/repositories/SqliteClaudeMessageResponseRepository.test.ts +313 -0
  40. package/src/adapter/repositories/SqliteClaudeMessageResponseRepository.ts +164 -0
  41. package/src/domain/entities/ClaudeMessageResponse.ts +31 -0
  42. package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +63 -0
  43. package/src/domain/usecases/HandleScheduledEventUseCase.ts +7 -0
  44. package/src/domain/usecases/UpdateRateLimitCacheUseCase.test.ts +81 -0
  45. package/src/domain/usecases/UpdateRateLimitCacheUseCase.ts +16 -0
  46. package/src/domain/usecases/adapter-interfaces/ClaudeMessageResponseRepository.ts +5 -0
  47. package/src/domain/usecases/adapter-interfaces/RateLimitCacheRepository.ts +9 -0
  48. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  49. package/types/adapter/proxy/ClaudeMessageResponseParser.d.ts +4 -0
  50. package/types/adapter/proxy/ClaudeMessageResponseParser.d.ts.map +1 -0
  51. package/types/adapter/proxy/RateLimitCache.d.ts.map +1 -1
  52. package/types/adapter/proxy/proxyEntry.d.ts +2 -1
  53. package/types/adapter/proxy/proxyEntry.d.ts.map +1 -1
  54. package/types/adapter/repositories/ProxyRateLimitCacheRepository.d.ts +10 -0
  55. package/types/adapter/repositories/ProxyRateLimitCacheRepository.d.ts.map +1 -0
  56. package/types/adapter/repositories/SqliteClaudeMessageResponseRepository.d.ts +10 -0
  57. package/types/adapter/repositories/SqliteClaudeMessageResponseRepository.d.ts.map +1 -0
  58. package/types/domain/entities/ClaudeMessageResponse.d.ts +32 -0
  59. package/types/domain/entities/ClaudeMessageResponse.d.ts.map +1 -0
  60. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +3 -1
  61. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
  62. package/types/domain/usecases/UpdateRateLimitCacheUseCase.d.ts +9 -0
  63. package/types/domain/usecases/UpdateRateLimitCacheUseCase.d.ts.map +1 -0
  64. package/types/domain/usecases/adapter-interfaces/ClaudeMessageResponseRepository.d.ts +5 -0
  65. package/types/domain/usecases/adapter-interfaces/ClaudeMessageResponseRepository.d.ts.map +1 -0
  66. package/types/domain/usecases/adapter-interfaces/RateLimitCacheRepository.d.ts +9 -0
  67. package/types/domain/usecases/adapter-interfaces/RateLimitCacheRepository.d.ts.map +1 -0
@@ -0,0 +1,180 @@
1
+ import * as http from 'http';
2
+ import { ClaudeMessageResponse } from '../../domain/entities/ClaudeMessageResponse';
3
+ import { generateUlid } from '../repositories/SqliteClaudeMessageResponseRepository';
4
+
5
+ const pickHeader = (
6
+ headers: http.IncomingHttpHeaders,
7
+ key: string,
8
+ ): string | null => {
9
+ const value = headers[key];
10
+ if (Array.isArray(value)) return value[0] ?? null;
11
+ return value ?? null;
12
+ };
13
+
14
+ const parseNullableFloat = (value: string | null): number | null => {
15
+ if (value === null) return null;
16
+ const parsed = parseFloat(value);
17
+ return Number.isFinite(parsed) ? parsed : null;
18
+ };
19
+
20
+ const parseNullableInt = (value: unknown): number | null => {
21
+ if (value === null || value === undefined) return null;
22
+ const parsed =
23
+ typeof value === 'number' ? value : parseInt(String(value), 10);
24
+ return Number.isFinite(parsed) ? parsed : null;
25
+ };
26
+
27
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
28
+ value !== null && typeof value === 'object' && !Array.isArray(value);
29
+
30
+ const extractUsage = (
31
+ body: Record<string, unknown>,
32
+ ): {
33
+ inputTokens: number | null;
34
+ outputTokens: number | null;
35
+ cacheCreationInputTokens: number | null;
36
+ cacheReadInputTokens: number | null;
37
+ ephemeral5mInputTokens: number | null;
38
+ ephemeral1hInputTokens: number | null;
39
+ } => {
40
+ const usage = body['usage'];
41
+ if (!isRecord(usage)) {
42
+ return {
43
+ inputTokens: null,
44
+ outputTokens: null,
45
+ cacheCreationInputTokens: null,
46
+ cacheReadInputTokens: null,
47
+ ephemeral5mInputTokens: null,
48
+ ephemeral1hInputTokens: null,
49
+ };
50
+ }
51
+ return {
52
+ inputTokens: parseNullableInt(usage['input_tokens']),
53
+ outputTokens: parseNullableInt(usage['output_tokens']),
54
+ cacheCreationInputTokens: parseNullableInt(
55
+ usage['cache_creation_input_tokens'],
56
+ ),
57
+ cacheReadInputTokens: parseNullableInt(usage['cache_read_input_tokens']),
58
+ ephemeral5mInputTokens: parseNullableInt(
59
+ usage['ephemeral_5m_input_tokens'],
60
+ ),
61
+ ephemeral1hInputTokens: parseNullableInt(
62
+ usage['ephemeral_1h_input_tokens'],
63
+ ),
64
+ };
65
+ };
66
+
67
+ const extractRole = (body: Record<string, unknown>): string | null => {
68
+ const role = body['role'];
69
+ return typeof role === 'string' ? role : null;
70
+ };
71
+
72
+ const extractFirstContentRole = (
73
+ body: Record<string, unknown>,
74
+ ): string | null => {
75
+ const content = body['content'];
76
+ if (!Array.isArray(content)) return null;
77
+ const first: unknown = content[0];
78
+ if (!isRecord(first)) return null;
79
+ const role = first['role'];
80
+ return typeof role === 'string' ? role : null;
81
+ };
82
+
83
+ export const parseClaudeMessageResponse = (
84
+ tokenName: string,
85
+ httpStatus: number,
86
+ headers: http.IncomingHttpHeaders,
87
+ body: string,
88
+ ): ClaudeMessageResponse => {
89
+ let parsedBody: Record<string, unknown> = {};
90
+ try {
91
+ const parsed: unknown = JSON.parse(body);
92
+ if (isRecord(parsed)) {
93
+ parsedBody = parsed;
94
+ }
95
+ } catch {
96
+ parsedBody = {};
97
+ }
98
+
99
+ const errorObj = parsedBody['error'];
100
+ const errorRecord = isRecord(errorObj) ? errorObj : null;
101
+ const errorType =
102
+ errorRecord !== null && typeof errorRecord['type'] === 'string'
103
+ ? errorRecord['type']
104
+ : null;
105
+ const errorMessage =
106
+ errorRecord !== null && typeof errorRecord['message'] === 'string'
107
+ ? errorRecord['message']
108
+ : null;
109
+
110
+ const externalClaudeMessageId =
111
+ typeof parsedBody['id'] === 'string' ? parsedBody['id'] : null;
112
+
113
+ const model =
114
+ typeof parsedBody['model'] === 'string' ? parsedBody['model'] : null;
115
+
116
+ const role = extractRole(parsedBody) ?? extractFirstContentRole(parsedBody);
117
+
118
+ const stopReason =
119
+ typeof parsedBody['stop_reason'] === 'string'
120
+ ? parsedBody['stop_reason']
121
+ : null;
122
+ const stopSequence =
123
+ typeof parsedBody['stop_sequence'] === 'string'
124
+ ? parsedBody['stop_sequence']
125
+ : null;
126
+
127
+ const usage = extractUsage(parsedBody);
128
+
129
+ const retryAfterRaw = pickHeader(headers, 'retry-after');
130
+ const retryAfter = parseNullableFloat(retryAfterRaw);
131
+
132
+ return {
133
+ id: generateUlid(),
134
+ observedAt: new Date(),
135
+ tokenName,
136
+ externalClaudeMessageId,
137
+ externalClaudeRequestId: pickHeader(headers, 'x-request-id'),
138
+ httpStatus,
139
+ model,
140
+ role,
141
+ stopReason,
142
+ stopSequence,
143
+ inputTokens: usage.inputTokens,
144
+ outputTokens: usage.outputTokens,
145
+ cacheCreationInputTokens: usage.cacheCreationInputTokens,
146
+ cacheReadInputTokens: usage.cacheReadInputTokens,
147
+ ephemeral5mInputTokens: usage.ephemeral5mInputTokens,
148
+ ephemeral1hInputTokens: usage.ephemeral1hInputTokens,
149
+ serviceTier: pickHeader(headers, 'anthropic-service-tier'),
150
+ inferenceGeo: pickHeader(headers, 'anthropic-inference-geo'),
151
+ errorType,
152
+ errorMessage,
153
+ anthropicRatelimitUnifiedStatus: pickHeader(
154
+ headers,
155
+ 'anthropic-ratelimit-unified-status',
156
+ ),
157
+ anthropicRatelimitUnified5hStatus: pickHeader(
158
+ headers,
159
+ 'anthropic-ratelimit-unified-5h-status',
160
+ ),
161
+ anthropicRatelimitUnified5hUtilization: parseNullableFloat(
162
+ pickHeader(headers, 'anthropic-ratelimit-unified-5h-utilization'),
163
+ ),
164
+ anthropicRatelimitUnified5hReset: parseNullableFloat(
165
+ pickHeader(headers, 'anthropic-ratelimit-unified-5h-reset'),
166
+ ),
167
+ anthropicRatelimitUnified7dStatus: pickHeader(
168
+ headers,
169
+ 'anthropic-ratelimit-unified-7d-status',
170
+ ),
171
+ anthropicRatelimitUnified7dUtilization: parseNullableFloat(
172
+ pickHeader(headers, 'anthropic-ratelimit-unified-7d-utilization'),
173
+ ),
174
+ anthropicRatelimitUnified7dReset: parseNullableFloat(
175
+ pickHeader(headers, 'anthropic-ratelimit-unified-7d-reset'),
176
+ ),
177
+ retryAfter,
178
+ anthropicOrganizationId: pickHeader(headers, 'anthropic-organization-id'),
179
+ };
180
+ };
@@ -220,6 +220,66 @@ describe('RateLimitCache', () => {
220
220
  });
221
221
  });
222
222
 
223
+ describe('writeRateLimit stores all anthropic-ratelimit-* headers', () => {
224
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
225
+ value !== null && typeof value === 'object' && !Array.isArray(value);
226
+
227
+ const readStoredHeaders = (token: string): Record<string, unknown> => {
228
+ const filePath = cachePathForToken(token);
229
+ const raw: unknown = JSON.parse(fs.readFileSync(filePath, 'utf8'));
230
+ if (isRecord(raw) && isRecord(raw.headers)) {
231
+ return raw.headers;
232
+ }
233
+ return {};
234
+ };
235
+
236
+ it('should store any anthropic-ratelimit-* header present in the response', () => {
237
+ const token = 'extra-header-token';
238
+ writeRateLimit(token, {
239
+ 'anthropic-ratelimit-unified-status': 'allowed',
240
+ 'anthropic-ratelimit-unified-5h-status': 'allowed',
241
+ 'anthropic-ratelimit-unified-5h-reset': '1700000000',
242
+ 'anthropic-ratelimit-unified-5h-utilization': '10',
243
+ 'anthropic-ratelimit-unified-7d-status': 'allowed',
244
+ 'anthropic-ratelimit-unified-7d-reset': '1700100000',
245
+ 'anthropic-ratelimit-unified-7d-utilization': '5',
246
+ 'anthropic-ratelimit-unified-reset': '1700000000',
247
+ 'anthropic-ratelimit-requests-limit': '100',
248
+ 'anthropic-ratelimit-requests-remaining': '99',
249
+ 'content-type': 'application/json',
250
+ });
251
+ const storedHeaders = readStoredHeaders(token);
252
+ expect(storedHeaders['anthropic-ratelimit-unified-reset']).toBe(
253
+ '1700000000',
254
+ );
255
+ expect(storedHeaders['anthropic-ratelimit-requests-limit']).toBe('100');
256
+ expect(storedHeaders['anthropic-ratelimit-requests-remaining']).toBe(
257
+ '99',
258
+ );
259
+ expect(storedHeaders['content-type']).toBeUndefined();
260
+ });
261
+
262
+ it('should not store non-anthropic-ratelimit headers', () => {
263
+ const token = 'non-ratelimit-header-token';
264
+ writeRateLimit(token, {
265
+ 'anthropic-ratelimit-unified-status': 'allowed',
266
+ 'anthropic-ratelimit-unified-5h-status': 'allowed',
267
+ 'anthropic-ratelimit-unified-5h-reset': '1700000000',
268
+ 'anthropic-ratelimit-unified-5h-utilization': '10',
269
+ 'anthropic-ratelimit-unified-7d-status': 'allowed',
270
+ 'anthropic-ratelimit-unified-7d-reset': '1700100000',
271
+ 'anthropic-ratelimit-unified-7d-utilization': '5',
272
+ 'x-request-id': 'abc123',
273
+ 'content-type': 'application/json',
274
+ 'transfer-encoding': 'chunked',
275
+ });
276
+ const storedHeaders = readStoredHeaders(token);
277
+ expect(storedHeaders['x-request-id']).toBeUndefined();
278
+ expect(storedHeaders['content-type']).toBeUndefined();
279
+ expect(storedHeaders['transfer-encoding']).toBeUndefined();
280
+ });
281
+ });
282
+
223
283
  describe('parseModelRateLimitsFromBody', () => {
224
284
  it('should extract a rejected seven_day_sonnet limit from a rate_limit event body', () => {
225
285
  const body =
@@ -82,31 +82,18 @@ export const writeRateLimit = (
82
82
  };
83
83
  const filePath = path.join(dir, `${hashToken(token)}.json`);
84
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
+ }
85
94
  const payload = {
86
95
  ts: Date.now() / 1000,
87
- headers: {
88
- 'anthropic-ratelimit-unified-status': pick(
89
- 'anthropic-ratelimit-unified-status',
90
- ),
91
- 'anthropic-ratelimit-unified-5h-status': pick(
92
- 'anthropic-ratelimit-unified-5h-status',
93
- ),
94
- 'anthropic-ratelimit-unified-5h-reset': pick(
95
- 'anthropic-ratelimit-unified-5h-reset',
96
- ),
97
- 'anthropic-ratelimit-unified-5h-utilization': pick(
98
- 'anthropic-ratelimit-unified-5h-utilization',
99
- ),
100
- 'anthropic-ratelimit-unified-7d-status': pick(
101
- 'anthropic-ratelimit-unified-7d-status',
102
- ),
103
- 'anthropic-ratelimit-unified-7d-reset': pick(
104
- 'anthropic-ratelimit-unified-7d-reset',
105
- ),
106
- 'anthropic-ratelimit-unified-7d-utilization': pick(
107
- 'anthropic-ratelimit-unified-7d-utilization',
108
- ),
109
- },
96
+ headers: rateLimitHeaders,
110
97
  modelWeeklyLimits: readModelWeeklyLimits(existing),
111
98
  };
112
99
  fs.writeFileSync(filePath, JSON.stringify(payload));
@@ -2,10 +2,14 @@ import * as http from 'http';
2
2
  import * as https from 'https';
3
3
  import {
4
4
  PROXY_PORT,
5
+ hashToken,
5
6
  parseModelRateLimitsFromBody,
6
7
  writeModelRateLimit,
7
8
  writeRateLimit,
8
9
  } from './RateLimitCache';
10
+ import { ClaudeMessageResponseRepository } from '../../domain/usecases/adapter-interfaces/ClaudeMessageResponseRepository';
11
+ import { parseClaudeMessageResponse } from './ClaudeMessageResponseParser';
12
+ import { SqliteClaudeMessageResponseRepository } from '../repositories/SqliteClaudeMessageResponseRepository';
9
13
 
10
14
  const UPSTREAM_HOST = 'api.anthropic.com';
11
15
 
@@ -25,7 +29,10 @@ const extractToken = (
25
29
  return token.length > 0 ? token : null;
26
30
  };
27
31
 
28
- const startProxy = (port: number): void => {
32
+ const startProxy = (
33
+ port: number,
34
+ claudeMessageResponseRepository: ClaudeMessageResponseRepository | null = null,
35
+ ): void => {
29
36
  const server = http.createServer((clientRequest, clientResponse) => {
30
37
  const token = extractToken(clientRequest.headers['authorization']);
31
38
  const upstreamHeaders: Record<string, string | string[] | undefined> = {
@@ -55,13 +62,29 @@ const startProxy = (port: number): void => {
55
62
  inspectedBytes += chunk.length;
56
63
  });
57
64
  upstreamResponse.on('end', () => {
65
+ const body = Buffer.concat(inspectedChunks).toString('utf8');
58
66
  try {
59
- const body = Buffer.concat(inspectedChunks).toString('utf8');
60
67
  const limits = parseModelRateLimitsFromBody(body);
61
68
  writeModelRateLimit(token, limits);
62
69
  } catch (error) {
63
70
  console.error('Failed to write model rate limit cache:', error);
64
71
  }
72
+ if (claudeMessageResponseRepository !== null) {
73
+ try {
74
+ const response = parseClaudeMessageResponse(
75
+ hashToken(token),
76
+ upstreamResponse.statusCode ?? 0,
77
+ upstreamResponse.headers,
78
+ body,
79
+ );
80
+ claudeMessageResponseRepository.append(response);
81
+ } catch (error) {
82
+ console.error(
83
+ 'Failed to record Claude message response:',
84
+ error,
85
+ );
86
+ }
87
+ }
65
88
  });
66
89
  }
67
90
  clientResponse.writeHead(
@@ -86,7 +109,9 @@ const startProxy = (port: number): void => {
86
109
  };
87
110
 
88
111
  if (require.main === module) {
89
- startProxy(PROXY_PORT);
112
+ const dbPath = './db/claude_message_response.db';
113
+ const repository = new SqliteClaudeMessageResponseRepository(dbPath);
114
+ startProxy(PROXY_PORT, repository);
90
115
  }
91
116
 
92
117
  export { startProxy, extractToken };
@@ -0,0 +1,101 @@
1
+ const mockReadRateLimit = jest.fn();
2
+ const mockLoadTokens = jest.fn();
3
+
4
+ jest.mock('../proxy/RateLimitCache', () => ({
5
+ PROXY_PORT: 8787,
6
+ readRateLimit: mockReadRateLimit,
7
+ }));
8
+
9
+ jest.mock('../proxy/TokenListLoader', () => ({
10
+ loadTokens: mockLoadTokens,
11
+ }));
12
+
13
+ import { ProxyRateLimitCacheRepository } from './ProxyRateLimitCacheRepository';
14
+
15
+ describe('ProxyRateLimitCacheRepository', () => {
16
+ beforeEach(() => {
17
+ jest.clearAllMocks();
18
+ });
19
+
20
+ describe('getTokenRateLimitCaches', () => {
21
+ it('should return an empty list when no token path is configured', () => {
22
+ const repository = new ProxyRateLimitCacheRepository(null);
23
+
24
+ const result = repository.getTokenRateLimitCaches();
25
+
26
+ expect(result).toEqual([]);
27
+ expect(mockLoadTokens).not.toHaveBeenCalled();
28
+ });
29
+
30
+ it('should return an empty list when the token list cannot be loaded', () => {
31
+ mockLoadTokens.mockReturnValue(null);
32
+ const repository = new ProxyRateLimitCacheRepository('/tokens.json');
33
+
34
+ const result = repository.getTokenRateLimitCaches();
35
+
36
+ expect(result).toEqual([]);
37
+ });
38
+
39
+ const futureReset = Math.floor(Date.now() / 1000) + 3600;
40
+
41
+ it('should return fiveHourReset as unifiedReset for a token with a cached snapshot', () => {
42
+ mockLoadTokens.mockReturnValue(['token-a']);
43
+ mockReadRateLimit.mockReturnValue({
44
+ fiveHourUtilization: 42,
45
+ fiveHourReset: futureReset,
46
+ sevenDayUtilization: 0,
47
+ sevenDayReset: futureReset,
48
+ blocked: false,
49
+ rejected: false,
50
+ unifiedRejected: false,
51
+ fiveHourRejected: false,
52
+ sevenDayRejected: false,
53
+ modelWeeklyLimits: {},
54
+ });
55
+ const repository = new ProxyRateLimitCacheRepository('/tokens.json');
56
+
57
+ const result = repository.getTokenRateLimitCaches();
58
+
59
+ expect(result).toEqual([{ token: 'token-a', unifiedReset: futureReset }]);
60
+ });
61
+
62
+ it('should return unifiedReset of 0 when no snapshot exists for a token', () => {
63
+ mockLoadTokens.mockReturnValue(['token-a']);
64
+ mockReadRateLimit.mockReturnValue(null);
65
+ const repository = new ProxyRateLimitCacheRepository('/tokens.json');
66
+
67
+ const result = repository.getTokenRateLimitCaches();
68
+
69
+ expect(result).toEqual([{ token: 'token-a', unifiedReset: 0 }]);
70
+ });
71
+
72
+ it('should return entries for all tokens in the list', () => {
73
+ mockLoadTokens.mockReturnValue(['token-a', 'token-b']);
74
+ mockReadRateLimit.mockImplementation((token: string) => {
75
+ if (token === 'token-a') {
76
+ return {
77
+ fiveHourUtilization: 10,
78
+ fiveHourReset: futureReset,
79
+ sevenDayUtilization: 0,
80
+ sevenDayReset: futureReset,
81
+ blocked: false,
82
+ rejected: false,
83
+ unifiedRejected: false,
84
+ fiveHourRejected: false,
85
+ sevenDayRejected: false,
86
+ modelWeeklyLimits: {},
87
+ };
88
+ }
89
+ return null;
90
+ });
91
+ const repository = new ProxyRateLimitCacheRepository('/tokens.json');
92
+
93
+ const result = repository.getTokenRateLimitCaches();
94
+
95
+ expect(result).toEqual([
96
+ { token: 'token-a', unifiedReset: futureReset },
97
+ { token: 'token-b', unifiedReset: 0 },
98
+ ]);
99
+ });
100
+ });
101
+ });
@@ -0,0 +1,66 @@
1
+ import * as http from 'http';
2
+ import { RateLimitCacheRepository } from '../../domain/usecases/adapter-interfaces/RateLimitCacheRepository';
3
+ import { TokenRateLimitCache } from '../../domain/usecases/adapter-interfaces/RateLimitCacheRepository';
4
+ import { PROXY_PORT, readRateLimit } from '../proxy/RateLimitCache';
5
+ import { loadTokens } from '../proxy/TokenListLoader';
6
+
7
+ const HAIKU_MODEL = 'claude-haiku-4-5';
8
+
9
+ const PROBE_REQUEST_BODY = JSON.stringify({
10
+ model: HAIKU_MODEL,
11
+ max_tokens: 1,
12
+ messages: [{ role: 'user', content: 'hi' }],
13
+ });
14
+
15
+ export class ProxyRateLimitCacheRepository implements RateLimitCacheRepository {
16
+ constructor(
17
+ private readonly tokenListJsonPath: string | null,
18
+ private readonly port: number = PROXY_PORT,
19
+ ) {}
20
+
21
+ getTokenRateLimitCaches = (): TokenRateLimitCache[] => {
22
+ if (this.tokenListJsonPath === null) {
23
+ return [];
24
+ }
25
+ const tokens = loadTokens(this.tokenListJsonPath);
26
+ if (tokens === null) {
27
+ return [];
28
+ }
29
+ return tokens.map((token) => {
30
+ const snapshot = readRateLimit(token);
31
+ const unifiedReset = snapshot !== null ? snapshot.fiveHourReset : 0;
32
+ return { token, unifiedReset };
33
+ });
34
+ };
35
+
36
+ probeToken = async (token: string): Promise<void> => {
37
+ await new Promise<void>((resolve) => {
38
+ const request = http.request(
39
+ {
40
+ host: '127.0.0.1',
41
+ port: this.port,
42
+ method: 'POST',
43
+ path: '/v1/messages',
44
+ headers: {
45
+ 'content-type': 'application/json',
46
+ 'anthropic-version': '2023-06-01',
47
+ authorization: `Bearer ${token}`,
48
+ 'content-length': Buffer.byteLength(PROBE_REQUEST_BODY),
49
+ },
50
+ },
51
+ (response) => {
52
+ response.resume();
53
+ response.on('end', () => resolve());
54
+ },
55
+ );
56
+ request.on('error', (error) => {
57
+ console.error(
58
+ `[UpdateRateLimitCache] Probe request failed for token hash: ${error.message}`,
59
+ );
60
+ resolve();
61
+ });
62
+ request.write(PROBE_REQUEST_BODY);
63
+ request.end();
64
+ });
65
+ };
66
+ }