github-issue-tower-defence-management 1.69.4 → 1.69.6
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.
- package/.github/CODEOWNERS +5 -0
- package/.github/copilot-instructions.md +6 -0
- package/.github/dependabot.yml +17 -0
- package/.github/instructions/code-review.instructions.md +7 -0
- package/.github/workflows/api-created_issue_pr.yml +6 -1
- package/.github/workflows/commit-lint.yml +5 -0
- package/.github/workflows/configs/commitlint.config.js +5 -0
- package/.github/workflows/create-pr.yml +6 -1
- package/.github/workflows/secret-scan.yml +51 -0
- package/.github/workflows/umino-project.yml +16 -3
- package/.gitleaks.toml +9 -0
- package/.prettierignore +1 -0
- package/CHANGELOG.md +14 -0
- package/README.md +1 -1
- package/bin/adapter/entry-points/cli/index.js +2 -2
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/adapter/entry-points/cli/projectConfig.js +24 -1
- package/bin/adapter/entry-points/cli/projectConfig.js.map +1 -1
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +16 -1
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
- package/bin/adapter/proxy/RateLimitCache.js +3 -0
- package/bin/adapter/proxy/RateLimitCache.js.map +1 -1
- package/bin/adapter/repositories/ProxyRateLimitCacheRepository.js +2 -1
- package/bin/adapter/repositories/ProxyRateLimitCacheRepository.js.map +1 -1
- package/bin/domain/usecases/UpdateRateLimitCacheUseCase.js +5 -1
- package/bin/domain/usecases/UpdateRateLimitCacheUseCase.js.map +1 -1
- package/package.json +1 -1
- package/renovate.json +5 -0
- package/src/adapter/entry-points/cli/index.test.ts +102 -0
- package/src/adapter/entry-points/cli/index.ts +2 -2
- package/src/adapter/entry-points/cli/projectConfig.ts +30 -1
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.test.ts +94 -0
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +47 -1
- package/src/adapter/proxy/RateLimitCache.test.ts +43 -0
- package/src/adapter/proxy/RateLimitCache.ts +4 -0
- package/src/adapter/repositories/ProxyRateLimitCacheRepository.test.ts +21 -6
- package/src/adapter/repositories/ProxyRateLimitCacheRepository.ts +2 -1
- package/src/domain/usecases/UpdateRateLimitCacheUseCase.test.ts +108 -11
- package/src/domain/usecases/UpdateRateLimitCacheUseCase.ts +7 -1
- package/src/domain/usecases/adapter-interfaces/RateLimitCacheRepository.ts +1 -0
- package/types/adapter/entry-points/cli/projectConfig.d.ts +1 -1
- package/types/adapter/entry-points/cli/projectConfig.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
- package/types/adapter/proxy/RateLimitCache.d.ts +1 -0
- package/types/adapter/proxy/RateLimitCache.d.ts.map +1 -1
- package/types/adapter/repositories/ProxyRateLimitCacheRepository.d.ts.map +1 -1
- package/types/domain/usecases/UpdateRateLimitCacheUseCase.d.ts.map +1 -1
- package/types/domain/usecases/adapter-interfaces/RateLimitCacheRepository.d.ts +1 -0
- package/types/domain/usecases/adapter-interfaces/RateLimitCacheRepository.d.ts.map +1 -1
|
@@ -350,6 +350,108 @@ codexHomeCandidates:
|
|
|
350
350
|
'.codex-main',
|
|
351
351
|
]);
|
|
352
352
|
});
|
|
353
|
+
|
|
354
|
+
it('should not warn when only known top-level keys are present', () => {
|
|
355
|
+
const readme = `<details>
|
|
356
|
+
<summary>config</summary>
|
|
357
|
+
defaultAgentName: 'agent'
|
|
358
|
+
maximumPreparingIssuesCount: 5
|
|
359
|
+
</details>`;
|
|
360
|
+
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
|
361
|
+
|
|
362
|
+
const result = parseProjectReadmeConfig(
|
|
363
|
+
readme,
|
|
364
|
+
'https://github.com/orgs/example/projects/1',
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
expect(result.defaultAgentName).toBe('agent');
|
|
368
|
+
expect(result.maximumPreparingIssuesCount).toBe(5);
|
|
369
|
+
expect(consoleWarnSpy).not.toHaveBeenCalled();
|
|
370
|
+
|
|
371
|
+
consoleWarnSpy.mockRestore();
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('should warn for each unknown top-level key with project URL', () => {
|
|
375
|
+
const readme = `<details>
|
|
376
|
+
<summary>config</summary>
|
|
377
|
+
unknownOne: 'value-one'
|
|
378
|
+
unknownTwo: 42
|
|
379
|
+
</details>`;
|
|
380
|
+
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
|
381
|
+
|
|
382
|
+
parseProjectReadmeConfig(
|
|
383
|
+
readme,
|
|
384
|
+
'https://github.com/orgs/example/projects/1',
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
388
|
+
'Unknown key "unknownOne" in project README config section (project URL: https://github.com/orgs/example/projects/1)',
|
|
389
|
+
);
|
|
390
|
+
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
391
|
+
'Unknown key "unknownTwo" in project README config section (project URL: https://github.com/orgs/example/projects/1)',
|
|
392
|
+
);
|
|
393
|
+
expect(consoleWarnSpy).toHaveBeenCalledTimes(2);
|
|
394
|
+
|
|
395
|
+
consoleWarnSpy.mockRestore();
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it('should warn only for unknown keys in a mixed config', () => {
|
|
399
|
+
const readme = `<details>
|
|
400
|
+
<summary>config</summary>
|
|
401
|
+
defaultAgentName: 'agent'
|
|
402
|
+
unexpectedKey: 'oops'
|
|
403
|
+
</details>`;
|
|
404
|
+
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
|
405
|
+
|
|
406
|
+
const result = parseProjectReadmeConfig(
|
|
407
|
+
readme,
|
|
408
|
+
'https://github.com/orgs/example/projects/2',
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
expect(result.defaultAgentName).toBe('agent');
|
|
412
|
+
expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
|
|
413
|
+
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
414
|
+
'Unknown key "unexpectedKey" in project README config section (project URL: https://github.com/orgs/example/projects/2)',
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
consoleWarnSpy.mockRestore();
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it('should warn for a typo of a known key (maximumPreparingIssueCount missing trailing s)', () => {
|
|
421
|
+
const readme = `<details>
|
|
422
|
+
<summary>config</summary>
|
|
423
|
+
maximumPreparingIssueCount: 9
|
|
424
|
+
</details>`;
|
|
425
|
+
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
|
426
|
+
|
|
427
|
+
const result = parseProjectReadmeConfig(
|
|
428
|
+
readme,
|
|
429
|
+
'https://github.com/orgs/example/projects/3',
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
expect(result.maximumPreparingIssuesCount).toBeUndefined();
|
|
433
|
+
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
434
|
+
'Unknown key "maximumPreparingIssueCount" in project README config section (project URL: https://github.com/orgs/example/projects/3)',
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
consoleWarnSpy.mockRestore();
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it('should warn without project URL suffix when projectUrl is not provided', () => {
|
|
441
|
+
const readme = `<details>
|
|
442
|
+
<summary>config</summary>
|
|
443
|
+
mysteryKey: 'value'
|
|
444
|
+
</details>`;
|
|
445
|
+
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
|
446
|
+
|
|
447
|
+
parseProjectReadmeConfig(readme);
|
|
448
|
+
|
|
449
|
+
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
450
|
+
'Unknown key "mysteryKey" in project README config section',
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
consoleWarnSpy.mockRestore();
|
|
454
|
+
});
|
|
353
455
|
});
|
|
354
456
|
|
|
355
457
|
describe('mergeConfigs', () => {
|
|
@@ -161,7 +161,7 @@ program
|
|
|
161
161
|
if (tempProjectUrl) {
|
|
162
162
|
const readme = await fetchProjectReadme(tempProjectUrl, token);
|
|
163
163
|
if (readme) {
|
|
164
|
-
readmeOverrides = parseProjectReadmeConfig(readme);
|
|
164
|
+
readmeOverrides = parseProjectReadmeConfig(readme, tempProjectUrl);
|
|
165
165
|
}
|
|
166
166
|
}
|
|
167
167
|
|
|
@@ -348,7 +348,7 @@ program
|
|
|
348
348
|
if (tempProjectUrl) {
|
|
349
349
|
const readme = await fetchProjectReadme(tempProjectUrl, token);
|
|
350
350
|
if (readme) {
|
|
351
|
-
readmeOverrides = parseProjectReadmeConfig(readme);
|
|
351
|
+
readmeOverrides = parseProjectReadmeConfig(readme, tempProjectUrl);
|
|
352
352
|
}
|
|
353
353
|
}
|
|
354
354
|
|
|
@@ -58,6 +58,23 @@ const getStringArrayValue = (
|
|
|
58
58
|
export const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
59
59
|
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
60
60
|
|
|
61
|
+
const knownProjectReadmeConfigKeys = [
|
|
62
|
+
'defaultAgentName',
|
|
63
|
+
'defaultLlmModelName',
|
|
64
|
+
'defaultLlmAgentName',
|
|
65
|
+
'maximumPreparingIssuesCount',
|
|
66
|
+
'allowIssueCacheMinutes',
|
|
67
|
+
'utilizationPercentageThreshold',
|
|
68
|
+
'allowedIssueAuthors',
|
|
69
|
+
'thresholdForAutoReject',
|
|
70
|
+
'workflowBlockerResolvedWebhookUrl',
|
|
71
|
+
'preparationProcessCheckCommand',
|
|
72
|
+
'codexHomeCandidates',
|
|
73
|
+
'claudeCodeOauthTokenListJsonPath',
|
|
74
|
+
'awLogDirectoryPath',
|
|
75
|
+
'awLogStaleThresholdMinutes',
|
|
76
|
+
] as const;
|
|
77
|
+
|
|
61
78
|
export const loadConfigFile = (configFilePath: string): ConfigFile => {
|
|
62
79
|
try {
|
|
63
80
|
const content = fs.readFileSync(configFilePath, 'utf-8');
|
|
@@ -111,7 +128,10 @@ export const loadConfigFile = (configFilePath: string): ConfigFile => {
|
|
|
111
128
|
}
|
|
112
129
|
};
|
|
113
130
|
|
|
114
|
-
export const parseProjectReadmeConfig = (
|
|
131
|
+
export const parseProjectReadmeConfig = (
|
|
132
|
+
readme: string,
|
|
133
|
+
projectUrl?: string,
|
|
134
|
+
): ConfigFile => {
|
|
115
135
|
const detailsRegex =
|
|
116
136
|
/<details>\s*<summary>config<\/summary>([\s\S]*?)<\/details>/i;
|
|
117
137
|
const match = detailsRegex.exec(readme);
|
|
@@ -127,6 +147,15 @@ export const parseProjectReadmeConfig = (readme: string): ConfigFile => {
|
|
|
127
147
|
if (!isRecord(parsed)) {
|
|
128
148
|
return {};
|
|
129
149
|
}
|
|
150
|
+
const knownKeySet = new Set<string>(knownProjectReadmeConfigKeys);
|
|
151
|
+
const projectUrlSuffix = projectUrl ? ` (project URL: ${projectUrl})` : '';
|
|
152
|
+
for (const key of Object.keys(parsed)) {
|
|
153
|
+
if (!knownKeySet.has(key)) {
|
|
154
|
+
console.warn(
|
|
155
|
+
`Unknown key "${key}" in project README config section${projectUrlSuffix}`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
130
159
|
return {
|
|
131
160
|
defaultAgentName: getStringValue(parsed, 'defaultAgentName'),
|
|
132
161
|
defaultLlmModelName: getStringValue(parsed, 'defaultLlmModelName'),
|
|
@@ -457,4 +457,98 @@ claudeCodeOauthTokenListJsonPath: /readme/tokens.json
|
|
|
457
457
|
);
|
|
458
458
|
});
|
|
459
459
|
});
|
|
460
|
+
|
|
461
|
+
describe('effective config logging', () => {
|
|
462
|
+
let consoleLogSpy: jest.SpyInstance;
|
|
463
|
+
|
|
464
|
+
beforeEach(() => {
|
|
465
|
+
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
afterEach(() => {
|
|
469
|
+
consoleLogSpy.mockRestore();
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it('should log the effective values with configFile source when only the YAML config sets them', async () => {
|
|
473
|
+
const configWithPreparation = {
|
|
474
|
+
...validConfig,
|
|
475
|
+
startPreparation: {
|
|
476
|
+
defaultAgentName: 'yaml-agent',
|
|
477
|
+
defaultLlmModelName: 'yaml-model',
|
|
478
|
+
configFilePath: './config.yml',
|
|
479
|
+
maximumPreparingIssuesCount: 10,
|
|
480
|
+
},
|
|
481
|
+
};
|
|
482
|
+
mockFetchReturningReadme(null);
|
|
483
|
+
jest
|
|
484
|
+
.mocked(fs.readFileSync)
|
|
485
|
+
.mockReturnValue(YAML.stringify(configWithPreparation));
|
|
486
|
+
|
|
487
|
+
const handler = new HandleScheduledEventUseCaseHandler();
|
|
488
|
+
await handler.handle('config.yml', false);
|
|
489
|
+
|
|
490
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
491
|
+
'Effective maximumPreparingIssuesCount: 10 (source: configFile)',
|
|
492
|
+
);
|
|
493
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
494
|
+
'Effective defaultLlmModelName: yaml-model (source: configFile)',
|
|
495
|
+
);
|
|
496
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
497
|
+
'Effective defaultAgentName: yaml-agent (source: configFile)',
|
|
498
|
+
);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it('should log the effective values with readmeOverride source when the README config overrides them', async () => {
|
|
502
|
+
const readmeContent = `<details>
|
|
503
|
+
<summary>config</summary>
|
|
504
|
+
maximumPreparingIssuesCount: 3
|
|
505
|
+
defaultLlmModelName: readme-model
|
|
506
|
+
defaultAgentName: readme-agent
|
|
507
|
+
</details>`;
|
|
508
|
+
mockFetchReturningReadme(readmeContent);
|
|
509
|
+
const configWithPreparation = {
|
|
510
|
+
...validConfig,
|
|
511
|
+
startPreparation: {
|
|
512
|
+
defaultAgentName: 'yaml-agent',
|
|
513
|
+
defaultLlmModelName: 'yaml-model',
|
|
514
|
+
configFilePath: './config.yml',
|
|
515
|
+
maximumPreparingIssuesCount: 10,
|
|
516
|
+
},
|
|
517
|
+
};
|
|
518
|
+
jest
|
|
519
|
+
.mocked(fs.readFileSync)
|
|
520
|
+
.mockReturnValue(YAML.stringify(configWithPreparation));
|
|
521
|
+
|
|
522
|
+
const handler = new HandleScheduledEventUseCaseHandler();
|
|
523
|
+
await handler.handle('config.yml', false);
|
|
524
|
+
|
|
525
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
526
|
+
'Effective maximumPreparingIssuesCount: 3 (source: readmeOverride)',
|
|
527
|
+
);
|
|
528
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
529
|
+
'Effective defaultLlmModelName: readme-model (source: readmeOverride)',
|
|
530
|
+
);
|
|
531
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
532
|
+
'Effective defaultAgentName: readme-agent (source: readmeOverride)',
|
|
533
|
+
);
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it('should log null with unset (default) source when neither README nor config provides the value', async () => {
|
|
537
|
+
mockFetchReturningReadme(null);
|
|
538
|
+
jest.mocked(fs.readFileSync).mockReturnValue(YAML.stringify(validConfig));
|
|
539
|
+
|
|
540
|
+
const handler = new HandleScheduledEventUseCaseHandler();
|
|
541
|
+
await handler.handle('config.yml', false);
|
|
542
|
+
|
|
543
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
544
|
+
'Effective maximumPreparingIssuesCount: null (source: unset (default))',
|
|
545
|
+
);
|
|
546
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
547
|
+
'Effective defaultLlmModelName: null (source: unset (default))',
|
|
548
|
+
);
|
|
549
|
+
expect(consoleLogSpy).toHaveBeenCalledWith(
|
|
550
|
+
'Effective defaultAgentName: null (source: unset (default))',
|
|
551
|
+
);
|
|
552
|
+
});
|
|
553
|
+
});
|
|
460
554
|
});
|
|
@@ -94,7 +94,9 @@ export class HandleScheduledEventUseCaseHandler {
|
|
|
94
94
|
|
|
95
95
|
const managerToken = input.credentials.manager.github.token;
|
|
96
96
|
const readme = await fetchProjectReadme(input.projectUrl, managerToken);
|
|
97
|
-
const readmeConfig = readme
|
|
97
|
+
const readmeConfig = readme
|
|
98
|
+
? parseProjectReadmeConfig(readme, input.projectUrl)
|
|
99
|
+
: {};
|
|
98
100
|
|
|
99
101
|
const mergedInput = {
|
|
100
102
|
...input,
|
|
@@ -139,6 +141,50 @@ export class HandleScheduledEventUseCaseHandler {
|
|
|
139
141
|
: input.startPreparation,
|
|
140
142
|
};
|
|
141
143
|
|
|
144
|
+
type EffectiveConfigValue = string | number | null | undefined;
|
|
145
|
+
|
|
146
|
+
const resolveConfigSource = (
|
|
147
|
+
readmeValue: EffectiveConfigValue,
|
|
148
|
+
configFileValue: EffectiveConfigValue,
|
|
149
|
+
): 'readmeOverride' | 'configFile' | 'unset (default)' => {
|
|
150
|
+
if (readmeValue !== undefined && readmeValue !== null) {
|
|
151
|
+
return 'readmeOverride';
|
|
152
|
+
}
|
|
153
|
+
if (configFileValue !== undefined && configFileValue !== null) {
|
|
154
|
+
return 'configFile';
|
|
155
|
+
}
|
|
156
|
+
return 'unset (default)';
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const formatEffectiveConfig = (
|
|
160
|
+
value: EffectiveConfigValue,
|
|
161
|
+
readmeValue: EffectiveConfigValue,
|
|
162
|
+
configFileValue: EffectiveConfigValue,
|
|
163
|
+
): string =>
|
|
164
|
+
`${value ?? 'null'} (source: ${resolveConfigSource(readmeValue, configFileValue)})`;
|
|
165
|
+
|
|
166
|
+
console.log(
|
|
167
|
+
`Effective maximumPreparingIssuesCount: ${formatEffectiveConfig(
|
|
168
|
+
mergedInput.startPreparation?.maximumPreparingIssuesCount,
|
|
169
|
+
readmeConfig.maximumPreparingIssuesCount,
|
|
170
|
+
input.startPreparation?.maximumPreparingIssuesCount,
|
|
171
|
+
)}`,
|
|
172
|
+
);
|
|
173
|
+
console.log(
|
|
174
|
+
`Effective defaultLlmModelName: ${formatEffectiveConfig(
|
|
175
|
+
mergedInput.startPreparation?.defaultLlmModelName,
|
|
176
|
+
readmeConfig.defaultLlmModelName,
|
|
177
|
+
input.startPreparation?.defaultLlmModelName,
|
|
178
|
+
)}`,
|
|
179
|
+
);
|
|
180
|
+
console.log(
|
|
181
|
+
`Effective defaultAgentName: ${formatEffectiveConfig(
|
|
182
|
+
mergedInput.startPreparation?.defaultAgentName,
|
|
183
|
+
readmeConfig.defaultAgentName,
|
|
184
|
+
input.startPreparation?.defaultAgentName,
|
|
185
|
+
)}`,
|
|
186
|
+
);
|
|
187
|
+
|
|
142
188
|
const systemDateRepository = new SystemDateRepository();
|
|
143
189
|
const localStorageRepository = new LocalStorageRepository();
|
|
144
190
|
const googleSpreadsheetRepository = new GoogleSpreadsheetRepository(
|
|
@@ -218,6 +218,49 @@ describe('RateLimitCache', () => {
|
|
|
218
218
|
const snapshot = readRateLimit(token);
|
|
219
219
|
expect(snapshot?.modelWeeklyLimits).toEqual({});
|
|
220
220
|
});
|
|
221
|
+
|
|
222
|
+
it('should expose ts as lastUpdatedEpoch in the snapshot', () => {
|
|
223
|
+
const token = 'ts-snapshot-token';
|
|
224
|
+
const before = Date.now() / 1000;
|
|
225
|
+
writeRateLimit(token, {
|
|
226
|
+
'anthropic-ratelimit-unified-status': 'allowed',
|
|
227
|
+
'anthropic-ratelimit-unified-5h-status': 'allowed',
|
|
228
|
+
'anthropic-ratelimit-unified-5h-reset': '1700000000',
|
|
229
|
+
'anthropic-ratelimit-unified-5h-utilization': '10',
|
|
230
|
+
'anthropic-ratelimit-unified-7d-status': 'allowed',
|
|
231
|
+
'anthropic-ratelimit-unified-7d-reset': '1700100000',
|
|
232
|
+
'anthropic-ratelimit-unified-7d-utilization': '5',
|
|
233
|
+
});
|
|
234
|
+
const after = Date.now() / 1000;
|
|
235
|
+
const snapshot = readRateLimit(token);
|
|
236
|
+
expect(snapshot).not.toBeNull();
|
|
237
|
+
if (snapshot === null) return;
|
|
238
|
+
expect(snapshot.lastUpdatedEpoch).toBeGreaterThanOrEqual(before);
|
|
239
|
+
expect(snapshot.lastUpdatedEpoch).toBeLessThanOrEqual(after);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('should default lastUpdatedEpoch to 0 when the stored payload has no ts field', () => {
|
|
243
|
+
const token = 'no-ts-token';
|
|
244
|
+
fs.mkdirSync(cacheDir(), { recursive: true });
|
|
245
|
+
fs.writeFileSync(
|
|
246
|
+
cachePathForToken(token),
|
|
247
|
+
JSON.stringify({
|
|
248
|
+
headers: {
|
|
249
|
+
'anthropic-ratelimit-unified-status': 'allowed',
|
|
250
|
+
'anthropic-ratelimit-unified-5h-status': 'allowed',
|
|
251
|
+
'anthropic-ratelimit-unified-5h-reset': '1700000000',
|
|
252
|
+
'anthropic-ratelimit-unified-5h-utilization': '10',
|
|
253
|
+
'anthropic-ratelimit-unified-7d-status': 'allowed',
|
|
254
|
+
'anthropic-ratelimit-unified-7d-reset': '1700100000',
|
|
255
|
+
'anthropic-ratelimit-unified-7d-utilization': '5',
|
|
256
|
+
},
|
|
257
|
+
}),
|
|
258
|
+
);
|
|
259
|
+
const snapshot = readRateLimit(token);
|
|
260
|
+
expect(snapshot).not.toBeNull();
|
|
261
|
+
if (snapshot === null) return;
|
|
262
|
+
expect(snapshot.lastUpdatedEpoch).toBe(0);
|
|
263
|
+
});
|
|
221
264
|
});
|
|
222
265
|
|
|
223
266
|
describe('writeRateLimit stores all anthropic-ratelimit-* headers', () => {
|
|
@@ -19,6 +19,7 @@ export interface RateLimitSnapshot {
|
|
|
19
19
|
fiveHourRejected: boolean;
|
|
20
20
|
sevenDayRejected: boolean;
|
|
21
21
|
modelWeeklyLimits: Record<string, ModelWeeklyLimit>;
|
|
22
|
+
lastUpdatedEpoch: number;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
export const PROXY_PORT = 8787;
|
|
@@ -183,6 +184,8 @@ export const readRateLimit = (token: string): RateLimitSnapshot | null => {
|
|
|
183
184
|
const unifiedRejected = status === 'rejected';
|
|
184
185
|
const fiveHourRejected = fiveHourStatus === 'rejected';
|
|
185
186
|
const sevenDayRejected = sevenDayStatus === 'rejected';
|
|
187
|
+
const storedTs = parsed.ts;
|
|
188
|
+
const lastUpdatedEpoch = typeof storedTs === 'number' ? storedTs : 0;
|
|
186
189
|
return {
|
|
187
190
|
fiveHourUtilization: num('anthropic-ratelimit-unified-5h-utilization'),
|
|
188
191
|
fiveHourReset: num('anthropic-ratelimit-unified-5h-reset'),
|
|
@@ -197,6 +200,7 @@ export const readRateLimit = (token: string): RateLimitSnapshot | null => {
|
|
|
197
200
|
fiveHourRejected,
|
|
198
201
|
sevenDayRejected,
|
|
199
202
|
modelWeeklyLimits: readModelWeeklyLimits(parsed),
|
|
203
|
+
lastUpdatedEpoch,
|
|
200
204
|
};
|
|
201
205
|
} catch {
|
|
202
206
|
return null;
|
|
@@ -37,8 +37,9 @@ describe('ProxyRateLimitCacheRepository', () => {
|
|
|
37
37
|
});
|
|
38
38
|
|
|
39
39
|
const futureReset = Math.floor(Date.now() / 1000) + 3600;
|
|
40
|
+
const recentEpoch = Math.floor(Date.now() / 1000) - 60;
|
|
40
41
|
|
|
41
|
-
it('should return fiveHourReset as unifiedReset for a token with a cached snapshot', () => {
|
|
42
|
+
it('should return fiveHourReset as unifiedReset and ts as lastProbeEpoch for a token with a cached snapshot', () => {
|
|
42
43
|
mockLoadTokens.mockReturnValue(['token-a']);
|
|
43
44
|
mockReadRateLimit.mockReturnValue({
|
|
44
45
|
fiveHourUtilization: 42,
|
|
@@ -51,22 +52,31 @@ describe('ProxyRateLimitCacheRepository', () => {
|
|
|
51
52
|
fiveHourRejected: false,
|
|
52
53
|
sevenDayRejected: false,
|
|
53
54
|
modelWeeklyLimits: {},
|
|
55
|
+
lastUpdatedEpoch: recentEpoch,
|
|
54
56
|
});
|
|
55
57
|
const repository = new ProxyRateLimitCacheRepository('/tokens.json');
|
|
56
58
|
|
|
57
59
|
const result = repository.getTokenRateLimitCaches();
|
|
58
60
|
|
|
59
|
-
expect(result).toEqual([
|
|
61
|
+
expect(result).toEqual([
|
|
62
|
+
{
|
|
63
|
+
token: 'token-a',
|
|
64
|
+
unifiedReset: futureReset,
|
|
65
|
+
lastProbeEpoch: recentEpoch,
|
|
66
|
+
},
|
|
67
|
+
]);
|
|
60
68
|
});
|
|
61
69
|
|
|
62
|
-
it('should return unifiedReset of 0 when no snapshot exists for a token', () => {
|
|
70
|
+
it('should return unifiedReset of 0 and lastProbeEpoch of 0 when no snapshot exists for a token', () => {
|
|
63
71
|
mockLoadTokens.mockReturnValue(['token-a']);
|
|
64
72
|
mockReadRateLimit.mockReturnValue(null);
|
|
65
73
|
const repository = new ProxyRateLimitCacheRepository('/tokens.json');
|
|
66
74
|
|
|
67
75
|
const result = repository.getTokenRateLimitCaches();
|
|
68
76
|
|
|
69
|
-
expect(result).toEqual([
|
|
77
|
+
expect(result).toEqual([
|
|
78
|
+
{ token: 'token-a', unifiedReset: 0, lastProbeEpoch: 0 },
|
|
79
|
+
]);
|
|
70
80
|
});
|
|
71
81
|
|
|
72
82
|
it('should return entries for all tokens in the list', () => {
|
|
@@ -84,6 +94,7 @@ describe('ProxyRateLimitCacheRepository', () => {
|
|
|
84
94
|
fiveHourRejected: false,
|
|
85
95
|
sevenDayRejected: false,
|
|
86
96
|
modelWeeklyLimits: {},
|
|
97
|
+
lastUpdatedEpoch: recentEpoch,
|
|
87
98
|
};
|
|
88
99
|
}
|
|
89
100
|
return null;
|
|
@@ -93,8 +104,12 @@ describe('ProxyRateLimitCacheRepository', () => {
|
|
|
93
104
|
const result = repository.getTokenRateLimitCaches();
|
|
94
105
|
|
|
95
106
|
expect(result).toEqual([
|
|
96
|
-
{
|
|
97
|
-
|
|
107
|
+
{
|
|
108
|
+
token: 'token-a',
|
|
109
|
+
unifiedReset: futureReset,
|
|
110
|
+
lastProbeEpoch: recentEpoch,
|
|
111
|
+
},
|
|
112
|
+
{ token: 'token-b', unifiedReset: 0, lastProbeEpoch: 0 },
|
|
98
113
|
]);
|
|
99
114
|
});
|
|
100
115
|
});
|
|
@@ -29,7 +29,8 @@ export class ProxyRateLimitCacheRepository implements RateLimitCacheRepository {
|
|
|
29
29
|
return tokens.map((token) => {
|
|
30
30
|
const snapshot = readRateLimit(token);
|
|
31
31
|
const unifiedReset = snapshot !== null ? snapshot.fiveHourReset : 0;
|
|
32
|
-
|
|
32
|
+
const lastProbeEpoch = snapshot !== null ? snapshot.lastUpdatedEpoch : 0;
|
|
33
|
+
return { token, unifiedReset, lastProbeEpoch };
|
|
33
34
|
});
|
|
34
35
|
};
|
|
35
36
|
|
|
@@ -12,10 +12,14 @@ describe('UpdateRateLimitCacheUseCase', () => {
|
|
|
12
12
|
});
|
|
13
13
|
|
|
14
14
|
it('should probe a token whose unifiedReset is in the past', async () => {
|
|
15
|
-
const pastReset = 1000000000;
|
|
16
15
|
const nowEpochSeconds = 1000000100;
|
|
16
|
+
const pastReset = 1000000000;
|
|
17
17
|
mockRateLimitCacheRepository.getTokenRateLimitCaches.mockReturnValue([
|
|
18
|
-
{
|
|
18
|
+
{
|
|
19
|
+
token: 'token-expired',
|
|
20
|
+
unifiedReset: pastReset,
|
|
21
|
+
lastProbeEpoch: nowEpochSeconds,
|
|
22
|
+
},
|
|
19
23
|
]);
|
|
20
24
|
mockRateLimitCacheRepository.probeToken.mockResolvedValue(undefined);
|
|
21
25
|
|
|
@@ -26,11 +30,16 @@ describe('UpdateRateLimitCacheUseCase', () => {
|
|
|
26
30
|
);
|
|
27
31
|
});
|
|
28
32
|
|
|
29
|
-
it('should not probe a token whose unifiedReset is in the future', async () => {
|
|
30
|
-
const futureReset = 9999999999;
|
|
33
|
+
it('should not probe a token whose unifiedReset is in the future and was probed within the last hour', async () => {
|
|
31
34
|
const nowEpochSeconds = 1000000000;
|
|
35
|
+
const futureReset = nowEpochSeconds + 1800;
|
|
36
|
+
const tenMinutesAgo = nowEpochSeconds - 600;
|
|
32
37
|
mockRateLimitCacheRepository.getTokenRateLimitCaches.mockReturnValue([
|
|
33
|
-
{
|
|
38
|
+
{
|
|
39
|
+
token: 'token-active',
|
|
40
|
+
unifiedReset: futureReset,
|
|
41
|
+
lastProbeEpoch: tenMinutesAgo,
|
|
42
|
+
},
|
|
34
43
|
]);
|
|
35
44
|
|
|
36
45
|
await useCase.run({ nowEpochSeconds });
|
|
@@ -39,12 +48,20 @@ describe('UpdateRateLimitCacheUseCase', () => {
|
|
|
39
48
|
});
|
|
40
49
|
|
|
41
50
|
it('should probe only tokens with expired reset when mixed tokens exist', async () => {
|
|
42
|
-
const pastReset = 1000000000;
|
|
43
|
-
const futureReset = 9999999999;
|
|
44
51
|
const nowEpochSeconds = 1000000100;
|
|
52
|
+
const pastReset = 1000000000;
|
|
53
|
+
const futureReset = nowEpochSeconds + 1800;
|
|
45
54
|
mockRateLimitCacheRepository.getTokenRateLimitCaches.mockReturnValue([
|
|
46
|
-
{
|
|
47
|
-
|
|
55
|
+
{
|
|
56
|
+
token: 'token-expired',
|
|
57
|
+
unifiedReset: pastReset,
|
|
58
|
+
lastProbeEpoch: nowEpochSeconds,
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
token: 'token-active',
|
|
62
|
+
unifiedReset: futureReset,
|
|
63
|
+
lastProbeEpoch: nowEpochSeconds,
|
|
64
|
+
},
|
|
48
65
|
]);
|
|
49
66
|
mockRateLimitCacheRepository.probeToken.mockResolvedValue(undefined);
|
|
50
67
|
|
|
@@ -57,10 +74,14 @@ describe('UpdateRateLimitCacheUseCase', () => {
|
|
|
57
74
|
});
|
|
58
75
|
|
|
59
76
|
it('should update the cache after probing by relying on the repository side effect', async () => {
|
|
60
|
-
const pastReset = 1000000000;
|
|
61
77
|
const nowEpochSeconds = 1000000100;
|
|
78
|
+
const pastReset = 1000000000;
|
|
62
79
|
mockRateLimitCacheRepository.getTokenRateLimitCaches.mockReturnValue([
|
|
63
|
-
{
|
|
80
|
+
{
|
|
81
|
+
token: 'token-expired',
|
|
82
|
+
unifiedReset: pastReset,
|
|
83
|
+
lastProbeEpoch: nowEpochSeconds,
|
|
84
|
+
},
|
|
64
85
|
]);
|
|
65
86
|
mockRateLimitCacheRepository.probeToken.mockResolvedValue(undefined);
|
|
66
87
|
|
|
@@ -78,4 +99,80 @@ describe('UpdateRateLimitCacheUseCase', () => {
|
|
|
78
99
|
|
|
79
100
|
expect(mockRateLimitCacheRepository.probeToken).not.toHaveBeenCalled();
|
|
80
101
|
});
|
|
102
|
+
|
|
103
|
+
it('should probe a token whose last probe was 61 minutes ago even when unifiedReset is in the future', async () => {
|
|
104
|
+
const nowEpochSeconds = 1000000000;
|
|
105
|
+
const futureReset = nowEpochSeconds + 7200;
|
|
106
|
+
const sixtyOneMinutesAgo = nowEpochSeconds - 61 * 60;
|
|
107
|
+
mockRateLimitCacheRepository.getTokenRateLimitCaches.mockReturnValue([
|
|
108
|
+
{
|
|
109
|
+
token: 'token-stale-probe',
|
|
110
|
+
unifiedReset: futureReset,
|
|
111
|
+
lastProbeEpoch: sixtyOneMinutesAgo,
|
|
112
|
+
},
|
|
113
|
+
]);
|
|
114
|
+
mockRateLimitCacheRepository.probeToken.mockResolvedValue(undefined);
|
|
115
|
+
|
|
116
|
+
await useCase.run({ nowEpochSeconds });
|
|
117
|
+
|
|
118
|
+
expect(mockRateLimitCacheRepository.probeToken).toHaveBeenCalledWith(
|
|
119
|
+
'token-stale-probe',
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('should not probe a token whose last probe was 10 minutes ago', async () => {
|
|
124
|
+
const nowEpochSeconds = 1000000000;
|
|
125
|
+
const futureReset = nowEpochSeconds + 7200;
|
|
126
|
+
const tenMinutesAgo = nowEpochSeconds - 10 * 60;
|
|
127
|
+
mockRateLimitCacheRepository.getTokenRateLimitCaches.mockReturnValue([
|
|
128
|
+
{
|
|
129
|
+
token: 'token-fresh-probe',
|
|
130
|
+
unifiedReset: futureReset,
|
|
131
|
+
lastProbeEpoch: tenMinutesAgo,
|
|
132
|
+
},
|
|
133
|
+
]);
|
|
134
|
+
|
|
135
|
+
await useCase.run({ nowEpochSeconds });
|
|
136
|
+
|
|
137
|
+
expect(mockRateLimitCacheRepository.probeToken).not.toHaveBeenCalled();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('should probe a token with unifiedReset in the past regardless of last probe time', async () => {
|
|
141
|
+
const nowEpochSeconds = 1000000000;
|
|
142
|
+
const pastReset = nowEpochSeconds - 60;
|
|
143
|
+
const oneMinuteAgo = nowEpochSeconds - 60;
|
|
144
|
+
mockRateLimitCacheRepository.getTokenRateLimitCaches.mockReturnValue([
|
|
145
|
+
{
|
|
146
|
+
token: 'token-expired-recent-probe',
|
|
147
|
+
unifiedReset: pastReset,
|
|
148
|
+
lastProbeEpoch: oneMinuteAgo,
|
|
149
|
+
},
|
|
150
|
+
]);
|
|
151
|
+
mockRateLimitCacheRepository.probeToken.mockResolvedValue(undefined);
|
|
152
|
+
|
|
153
|
+
await useCase.run({ nowEpochSeconds });
|
|
154
|
+
|
|
155
|
+
expect(mockRateLimitCacheRepository.probeToken).toHaveBeenCalledWith(
|
|
156
|
+
'token-expired-recent-probe',
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('should probe a token that has never been probed even when unifiedReset is in the future', async () => {
|
|
161
|
+
const nowEpochSeconds = 1000000000;
|
|
162
|
+
const futureReset = nowEpochSeconds + 7200;
|
|
163
|
+
mockRateLimitCacheRepository.getTokenRateLimitCaches.mockReturnValue([
|
|
164
|
+
{
|
|
165
|
+
token: 'token-never-probed',
|
|
166
|
+
unifiedReset: futureReset,
|
|
167
|
+
lastProbeEpoch: 0,
|
|
168
|
+
},
|
|
169
|
+
]);
|
|
170
|
+
mockRateLimitCacheRepository.probeToken.mockResolvedValue(undefined);
|
|
171
|
+
|
|
172
|
+
await useCase.run({ nowEpochSeconds });
|
|
173
|
+
|
|
174
|
+
expect(mockRateLimitCacheRepository.probeToken).toHaveBeenCalledWith(
|
|
175
|
+
'token-never-probed',
|
|
176
|
+
);
|
|
177
|
+
});
|
|
81
178
|
});
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { RateLimitCacheRepository } from './adapter-interfaces/RateLimitCacheRepository';
|
|
2
2
|
|
|
3
|
+
const HOURLY_PROBE_INTERVAL_SECONDS = 3600;
|
|
4
|
+
|
|
3
5
|
export class UpdateRateLimitCacheUseCase {
|
|
4
6
|
constructor(
|
|
5
7
|
private readonly rateLimitCacheRepository: RateLimitCacheRepository,
|
|
@@ -8,7 +10,11 @@ export class UpdateRateLimitCacheUseCase {
|
|
|
8
10
|
run = async (params: { nowEpochSeconds: number }): Promise<void> => {
|
|
9
11
|
const caches = this.rateLimitCacheRepository.getTokenRateLimitCaches();
|
|
10
12
|
for (const cache of caches) {
|
|
11
|
-
|
|
13
|
+
const unifiedResetExpired = cache.unifiedReset < params.nowEpochSeconds;
|
|
14
|
+
const hourlyProbeDue =
|
|
15
|
+
params.nowEpochSeconds - cache.lastProbeEpoch >=
|
|
16
|
+
HOURLY_PROBE_INTERVAL_SECONDS;
|
|
17
|
+
if (unifiedResetExpired || hourlyProbeDue) {
|
|
12
18
|
await this.rateLimitCacheRepository.probeToken(cache.token);
|
|
13
19
|
}
|
|
14
20
|
}
|