github-issue-tower-defence-management 1.126.3 → 1.128.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.
- package/.github/workflows/commit-lint.yml +1 -1
- package/CHANGELOG.md +14 -0
- package/README.md +5 -3
- package/bin/adapter/entry-points/cli/projectConfig.js +6 -0
- package/bin/adapter/entry-points/cli/projectConfig.js.map +1 -1
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +29 -16
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
- package/bin/adapter/entry-points/handlers/InTmuxByHumanSessionTokenCountHandler.js +1 -0
- package/bin/adapter/entry-points/handlers/InTmuxByHumanSessionTokenCountHandler.js.map +1 -1
- package/bin/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.js +5 -0
- package/bin/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.js.map +1 -1
- package/bin/adapter/entry-points/handlers/OauthTokenSelectHandler.js +5 -0
- package/bin/adapter/entry-points/handlers/OauthTokenSelectHandler.js.map +1 -1
- package/bin/adapter/entry-points/handlers/tokenStatusWriter.js +1 -0
- package/bin/adapter/entry-points/handlers/tokenStatusWriter.js.map +1 -1
- package/bin/adapter/proxy/RateLimitCache.js +35 -4
- package/bin/adapter/proxy/RateLimitCache.js.map +1 -1
- package/bin/adapter/proxy/proxyEntry.js +48 -0
- package/bin/adapter/proxy/proxyEntry.js.map +1 -1
- package/bin/domain/usecases/AssignNoAssigneeIssueToManagerUseCase.js +7 -0
- package/bin/domain/usecases/AssignNoAssigneeIssueToManagerUseCase.js.map +1 -1
- package/bin/domain/usecases/HandleScheduledEventUseCase.js +1 -0
- package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
- package/bin/domain/usecases/OauthTokenSelectUseCase.js +5 -2
- package/bin/domain/usecases/OauthTokenSelectUseCase.js.map +1 -1
- package/package.json +1 -1
- package/src/adapter/entry-points/cli/projectConfig.ts +14 -0
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.test.ts +66 -0
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +5 -1
- package/src/adapter/entry-points/handlers/InTmuxByHumanSessionTokenCountHandler.ts +1 -0
- package/src/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.test.ts +49 -1
- package/src/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.ts +7 -1
- package/src/adapter/entry-points/handlers/OauthTokenSelectHandler.test.ts +65 -1
- package/src/adapter/entry-points/handlers/OauthTokenSelectHandler.ts +11 -1
- package/src/adapter/entry-points/handlers/tokenStatusWriter.ts +1 -0
- package/src/adapter/proxy/RateLimitCache.test.ts +148 -0
- package/src/adapter/proxy/RateLimitCache.ts +56 -3
- package/src/adapter/proxy/proxyEntry.test.ts +126 -0
- package/src/adapter/proxy/proxyEntry.ts +60 -0
- package/src/domain/usecases/AssignNoAssigneeIssueToManagerUseCase.test.ts +95 -0
- package/src/domain/usecases/AssignNoAssigneeIssueToManagerUseCase.ts +9 -0
- package/src/domain/usecases/HandleScheduledEventUseCase.ts +2 -0
- package/src/domain/usecases/InTmuxByHumanSessionTokenCountUseCase.test.ts +1 -0
- package/src/domain/usecases/LiveSessionOauthTokenSelectUseCase.test.ts +2 -0
- package/src/domain/usecases/OauthTokenSelectUseCase.test.ts +25 -0
- package/src/domain/usecases/OauthTokenSelectUseCase.ts +6 -0
- package/types/adapter/entry-points/cli/projectConfig.d.ts +1 -0
- 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/entry-points/handlers/InTmuxByHumanSessionTokenCountHandler.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/OauthTokenSelectHandler.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/tokenStatusWriter.d.ts.map +1 -1
- package/types/adapter/proxy/RateLimitCache.d.ts +8 -0
- package/types/adapter/proxy/RateLimitCache.d.ts.map +1 -1
- package/types/adapter/proxy/proxyEntry.d.ts.map +1 -1
- package/types/domain/usecases/AssignNoAssigneeIssueToManagerUseCase.d.ts +1 -0
- package/types/domain/usecases/AssignNoAssigneeIssueToManagerUseCase.d.ts.map +1 -1
- package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +1 -0
- package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
- package/types/domain/usecases/OauthTokenSelectUseCase.d.ts +1 -0
- package/types/domain/usecases/OauthTokenSelectUseCase.d.ts.map +1 -1
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
SEVEN_DAY_MIN_FREE_RATIO,
|
|
10
10
|
} from '../../../domain/usecases/OauthTokenSelectUseCase';
|
|
11
11
|
import { ProcClaudeLiveSessionRepository } from '../../repositories/ProcClaudeLiveSessionRepository';
|
|
12
|
-
import { readRateLimit } from '../../proxy/RateLimitCache';
|
|
12
|
+
import { FABLE_LIMIT_TYPE, readRateLimit } from '../../proxy/RateLimitCache';
|
|
13
13
|
import { loadTokenEntries } from '../../proxy/TokenListLoader';
|
|
14
14
|
import {
|
|
15
15
|
resolveCacheDirectory,
|
|
@@ -63,6 +63,11 @@ export class LiveSessionOauthTokenSelectHandler {
|
|
|
63
63
|
|
|
64
64
|
const candidates: OauthTokenCandidate[] = entries.map(({ name, token }) => {
|
|
65
65
|
const snapshot = readRateLimit(token, cacheDirectory);
|
|
66
|
+
const fableLimit = snapshot?.modelWeeklyLimits[FABLE_LIMIT_TYPE];
|
|
67
|
+
const fableRejected =
|
|
68
|
+
fableLimit !== undefined &&
|
|
69
|
+
fableLimit.rejected &&
|
|
70
|
+
input.nowEpochSeconds <= fableLimit.resetsAt;
|
|
66
71
|
return {
|
|
67
72
|
name,
|
|
68
73
|
token,
|
|
@@ -77,6 +82,7 @@ export class LiveSessionOauthTokenSelectHandler {
|
|
|
77
82
|
},
|
|
78
83
|
subscriptionDisabled: snapshot?.subscriptionDisabled ?? false,
|
|
79
84
|
unifiedRejected: snapshot?.unifiedRejected ?? false,
|
|
85
|
+
fableRejected,
|
|
80
86
|
};
|
|
81
87
|
});
|
|
82
88
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as os from 'os';
|
|
3
3
|
import * as path from 'path';
|
|
4
|
-
import { hashToken } from '../../proxy/RateLimitCache';
|
|
4
|
+
import { FABLE_LIMIT_TYPE, hashToken } from '../../proxy/RateLimitCache';
|
|
5
5
|
import {
|
|
6
6
|
OauthTokenSelectHandler,
|
|
7
7
|
resolveCacheDirectory,
|
|
@@ -100,6 +100,28 @@ describe('OauthTokenSelectHandler', () => {
|
|
|
100
100
|
);
|
|
101
101
|
};
|
|
102
102
|
|
|
103
|
+
const writeFableRejectionCache = (token: string, resetsAt: number): void => {
|
|
104
|
+
const payload = {
|
|
105
|
+
ts: NOW,
|
|
106
|
+
headers: {
|
|
107
|
+
'anthropic-ratelimit-unified-status': 'allowed',
|
|
108
|
+
'anthropic-ratelimit-unified-5h-status': 'allowed',
|
|
109
|
+
'anthropic-ratelimit-unified-5h-reset': String(NOW + HOUR),
|
|
110
|
+
'anthropic-ratelimit-unified-5h-utilization': '0.1',
|
|
111
|
+
'anthropic-ratelimit-unified-7d-status': 'allowed',
|
|
112
|
+
'anthropic-ratelimit-unified-7d-reset': String(NOW + DAY),
|
|
113
|
+
'anthropic-ratelimit-unified-7d-utilization': '0.1',
|
|
114
|
+
},
|
|
115
|
+
modelWeeklyLimits: {
|
|
116
|
+
[FABLE_LIMIT_TYPE]: { rejected: true, resetsAt },
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
fs.writeFileSync(
|
|
120
|
+
path.join(cacheDirectory, `${hashToken(token)}.json`),
|
|
121
|
+
JSON.stringify(payload),
|
|
122
|
+
);
|
|
123
|
+
};
|
|
124
|
+
|
|
103
125
|
it('selects the eligible token with the soonest 7d reset', () => {
|
|
104
126
|
writeTokenList([
|
|
105
127
|
{ name: 'far', token: 'fake-far' },
|
|
@@ -248,6 +270,48 @@ describe('OauthTokenSelectHandler', () => {
|
|
|
248
270
|
expect(output.diagnostics.join('\n')).toContain('rejected');
|
|
249
271
|
});
|
|
250
272
|
|
|
273
|
+
it('excludes a token whose fable marker is set and not yet expired', () => {
|
|
274
|
+
writeTokenList([
|
|
275
|
+
{ name: 'fable-out', token: 'fake-fable-out' },
|
|
276
|
+
{ name: 'active', token: 'fake-active' },
|
|
277
|
+
]);
|
|
278
|
+
writeFableRejectionCache('fake-fable-out', NOW + HOUR);
|
|
279
|
+
writeCache('fake-active', {
|
|
280
|
+
fiveHourUtilization: 0.1,
|
|
281
|
+
fiveHourReset: NOW + HOUR,
|
|
282
|
+
sevenDayUtilization: 0.1,
|
|
283
|
+
sevenDayReset: NOW + DAY,
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
const handler = new OauthTokenSelectHandler();
|
|
287
|
+
const output = handler.handle({
|
|
288
|
+
tokenListJsonPath: tokenListPath,
|
|
289
|
+
cacheDirectory,
|
|
290
|
+
nowEpochSeconds: NOW,
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
expect(output.selectedName).toBe('active');
|
|
294
|
+
expect(output.diagnostics.join('\n')).toContain(
|
|
295
|
+
'fable weekly limit exhausted',
|
|
296
|
+
);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('treats a token whose fable marker has expired as usable', () => {
|
|
300
|
+
writeTokenList([
|
|
301
|
+
{ name: 'fable-recovered', token: 'fake-fable-recovered' },
|
|
302
|
+
]);
|
|
303
|
+
writeFableRejectionCache('fake-fable-recovered', NOW - HOUR);
|
|
304
|
+
|
|
305
|
+
const handler = new OauthTokenSelectHandler();
|
|
306
|
+
const output = handler.handle({
|
|
307
|
+
tokenListJsonPath: tokenListPath,
|
|
308
|
+
cacheDirectory,
|
|
309
|
+
nowEpochSeconds: NOW,
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
expect(output.selectedName).toBe('fable-recovered');
|
|
313
|
+
});
|
|
314
|
+
|
|
251
315
|
it('does not exclude a token solely because overage is disabled', () => {
|
|
252
316
|
writeTokenList([{ name: 'overage', token: 'fake-overage' }]);
|
|
253
317
|
writeCacheWithHeaders('fake-overage', {
|
|
@@ -3,7 +3,11 @@ import {
|
|
|
3
3
|
OauthTokenSelectResult,
|
|
4
4
|
OauthTokenSelectUseCase,
|
|
5
5
|
} from '../../../domain/usecases/OauthTokenSelectUseCase';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
FABLE_LIMIT_TYPE,
|
|
8
|
+
cacheDir,
|
|
9
|
+
readRateLimit,
|
|
10
|
+
} from '../../proxy/RateLimitCache';
|
|
7
11
|
import { loadTokenEntries } from '../../proxy/TokenListLoader';
|
|
8
12
|
|
|
9
13
|
export type OauthTokenSelectHandlerInput = {
|
|
@@ -81,6 +85,11 @@ export class OauthTokenSelectHandler {
|
|
|
81
85
|
|
|
82
86
|
const candidates: OauthTokenCandidate[] = entries.map(({ name, token }) => {
|
|
83
87
|
const snapshot = readRateLimit(token, cacheDirectory);
|
|
88
|
+
const fableLimit = snapshot?.modelWeeklyLimits[FABLE_LIMIT_TYPE];
|
|
89
|
+
const fableRejected =
|
|
90
|
+
fableLimit !== undefined &&
|
|
91
|
+
fableLimit.rejected &&
|
|
92
|
+
input.nowEpochSeconds <= fableLimit.resetsAt;
|
|
84
93
|
return {
|
|
85
94
|
name,
|
|
86
95
|
token,
|
|
@@ -95,6 +104,7 @@ export class OauthTokenSelectHandler {
|
|
|
95
104
|
},
|
|
96
105
|
subscriptionDisabled: snapshot?.subscriptionDisabled ?? false,
|
|
97
106
|
unifiedRejected: snapshot?.unifiedRejected ?? false,
|
|
107
|
+
fableRejected,
|
|
98
108
|
};
|
|
99
109
|
});
|
|
100
110
|
|
|
@@ -199,6 +199,7 @@ export const writeTokenStatus = (params: TokenStatusWriterParams): void => {
|
|
|
199
199
|
snapshot: null,
|
|
200
200
|
subscriptionDisabled: false,
|
|
201
201
|
unifiedRejected: false,
|
|
202
|
+
fableRejected: false,
|
|
202
203
|
}));
|
|
203
204
|
const machineWideInTmuxByHumanIssues: Issue[] = [
|
|
204
205
|
...machineWideInTmuxByHumanUrls,
|
|
@@ -4,13 +4,17 @@ import * as path from 'path';
|
|
|
4
4
|
import {
|
|
5
5
|
cacheDir,
|
|
6
6
|
cachePathForToken,
|
|
7
|
+
FABLE_LIMIT_TYPE,
|
|
7
8
|
hashToken,
|
|
8
9
|
HEADERLESS_429_DEFAULT_COOLDOWN_SECONDS,
|
|
9
10
|
HEADERLESS_429_MAX_COOLDOWN_SECONDS,
|
|
11
|
+
isFableModel,
|
|
10
12
|
parseModelRateLimitsFromBody,
|
|
11
13
|
parseModelRateLimitsFromHeaders,
|
|
14
|
+
parseSevenDayRejection,
|
|
12
15
|
PERMISSION_DISABLED_COOLDOWN_SECONDS,
|
|
13
16
|
readRateLimit,
|
|
17
|
+
writeFableRejection,
|
|
14
18
|
writeModelRateLimit,
|
|
15
19
|
writeRateLimit,
|
|
16
20
|
writeSubscriptionDisabled,
|
|
@@ -699,6 +703,149 @@ describe('RateLimitCache', () => {
|
|
|
699
703
|
});
|
|
700
704
|
});
|
|
701
705
|
|
|
706
|
+
describe('isFableModel', () => {
|
|
707
|
+
it('should return true for the fable model id', () => {
|
|
708
|
+
expect(isFableModel('claude-fable-5')).toBe(true);
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
it('should be case insensitive', () => {
|
|
712
|
+
expect(isFableModel('Claude-FABLE-5')).toBe(true);
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
it('should return false for sonnet and opus models', () => {
|
|
716
|
+
expect(isFableModel('claude-sonnet-4-6')).toBe(false);
|
|
717
|
+
expect(isFableModel('claude-opus-4-8')).toBe(false);
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
it('should return false for a null model', () => {
|
|
721
|
+
expect(isFableModel(null)).toBe(false);
|
|
722
|
+
});
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
describe('writeFableRejection', () => {
|
|
726
|
+
it('should persist a rejected fable weekly limit readable by readRateLimit', () => {
|
|
727
|
+
const token = 'fable-rejection-token';
|
|
728
|
+
const nowEpochSeconds = Date.now() / 1000;
|
|
729
|
+
writeFableRejection(token, 120);
|
|
730
|
+
const snapshot = readRateLimit(token);
|
|
731
|
+
const fableLimit = snapshot?.modelWeeklyLimits[FABLE_LIMIT_TYPE];
|
|
732
|
+
expect(fableLimit?.rejected).toBe(true);
|
|
733
|
+
expect(fableLimit?.resetsAt).toBeGreaterThanOrEqual(
|
|
734
|
+
nowEpochSeconds + 120,
|
|
735
|
+
);
|
|
736
|
+
expect(fableLimit?.resetsAt).toBeLessThanOrEqual(
|
|
737
|
+
nowEpochSeconds + 120 + 5,
|
|
738
|
+
);
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
it('should cap the reset from an oversized retry-after at the max cooldown', () => {
|
|
742
|
+
const token = 'fable-rejection-cap-token';
|
|
743
|
+
const nowEpochSeconds = Date.now() / 1000;
|
|
744
|
+
writeFableRejection(token, HEADERLESS_429_MAX_COOLDOWN_SECONDS + 10_000);
|
|
745
|
+
const fableLimit =
|
|
746
|
+
readRateLimit(token)?.modelWeeklyLimits[FABLE_LIMIT_TYPE];
|
|
747
|
+
expect(fableLimit?.resetsAt).toBeLessThanOrEqual(
|
|
748
|
+
nowEpochSeconds + HEADERLESS_429_MAX_COOLDOWN_SECONDS + 5,
|
|
749
|
+
);
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
it('should use the default cooldown when retry-after is absent', () => {
|
|
753
|
+
const token = 'fable-rejection-default-token';
|
|
754
|
+
const nowEpochSeconds = Date.now() / 1000;
|
|
755
|
+
writeFableRejection(token, null);
|
|
756
|
+
const fableLimit =
|
|
757
|
+
readRateLimit(token)?.modelWeeklyLimits[FABLE_LIMIT_TYPE];
|
|
758
|
+
expect(fableLimit?.resetsAt).toBeGreaterThanOrEqual(
|
|
759
|
+
nowEpochSeconds + HEADERLESS_429_DEFAULT_COOLDOWN_SECONDS,
|
|
760
|
+
);
|
|
761
|
+
expect(fableLimit?.resetsAt).toBeLessThanOrEqual(
|
|
762
|
+
nowEpochSeconds + HEADERLESS_429_DEFAULT_COOLDOWN_SECONDS + 5,
|
|
763
|
+
);
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
it('should preserve other model weekly limits already recorded', () => {
|
|
767
|
+
const token = 'fable-rejection-merge-token';
|
|
768
|
+
writeModelRateLimit(token, {
|
|
769
|
+
seven_day_sonnet: { rejected: true, resetsAt: 1779642000 },
|
|
770
|
+
});
|
|
771
|
+
writeFableRejection(token, 90);
|
|
772
|
+
const limits = readRateLimit(token)?.modelWeeklyLimits;
|
|
773
|
+
expect(limits?.seven_day_sonnet).toEqual({
|
|
774
|
+
rejected: true,
|
|
775
|
+
resetsAt: 1779642000,
|
|
776
|
+
});
|
|
777
|
+
expect(limits?.[FABLE_LIMIT_TYPE]?.rejected).toBe(true);
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
it('should use the seven-day reset for resetsAt when provided instead of the retry-after cooldown', () => {
|
|
781
|
+
const token = 'fable-rejection-sevenday-reset-token';
|
|
782
|
+
const sevenDayReset = 1893456000;
|
|
783
|
+
writeFableRejection(token, 120, sevenDayReset);
|
|
784
|
+
const fableLimit =
|
|
785
|
+
readRateLimit(token)?.modelWeeklyLimits[FABLE_LIMIT_TYPE];
|
|
786
|
+
expect(fableLimit?.rejected).toBe(true);
|
|
787
|
+
expect(fableLimit?.resetsAt).toBe(sevenDayReset);
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
it('should fall back to the retry-after cooldown when the seven-day reset is null', () => {
|
|
791
|
+
const token = 'fable-rejection-null-reset-token';
|
|
792
|
+
const nowEpochSeconds = Date.now() / 1000;
|
|
793
|
+
writeFableRejection(token, 120, null);
|
|
794
|
+
const fableLimit =
|
|
795
|
+
readRateLimit(token)?.modelWeeklyLimits[FABLE_LIMIT_TYPE];
|
|
796
|
+
expect(fableLimit?.resetsAt).toBeGreaterThanOrEqual(
|
|
797
|
+
nowEpochSeconds + 120,
|
|
798
|
+
);
|
|
799
|
+
expect(fableLimit?.resetsAt).toBeLessThanOrEqual(
|
|
800
|
+
nowEpochSeconds + 120 + 5,
|
|
801
|
+
);
|
|
802
|
+
});
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
describe('parseSevenDayRejection', () => {
|
|
806
|
+
it('should report a rejection and the reset epoch when the 7-day status is rejected', () => {
|
|
807
|
+
expect(
|
|
808
|
+
parseSevenDayRejection({
|
|
809
|
+
'anthropic-ratelimit-unified-7d-status': 'rejected',
|
|
810
|
+
'anthropic-ratelimit-unified-7d-reset': '1893456000',
|
|
811
|
+
}),
|
|
812
|
+
).toEqual({ sevenDayRejected: true, sevenDayReset: 1893456000 });
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
it('should report no rejection when only the 5-hour status is rejected', () => {
|
|
816
|
+
expect(
|
|
817
|
+
parseSevenDayRejection({
|
|
818
|
+
'anthropic-ratelimit-unified-5h-status': 'rejected',
|
|
819
|
+
'anthropic-ratelimit-unified-7d-status': 'allowed',
|
|
820
|
+
}),
|
|
821
|
+
).toEqual({ sevenDayRejected: false, sevenDayReset: null });
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
it('should report no rejection when no rate-limit headers are present', () => {
|
|
825
|
+
expect(parseSevenDayRejection({})).toEqual({
|
|
826
|
+
sevenDayRejected: false,
|
|
827
|
+
sevenDayReset: null,
|
|
828
|
+
});
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
it('should return a null reset when the 7-day status is rejected but no reset header is present', () => {
|
|
832
|
+
expect(
|
|
833
|
+
parseSevenDayRejection({
|
|
834
|
+
'anthropic-ratelimit-unified-7d-status': 'rejected',
|
|
835
|
+
}),
|
|
836
|
+
).toEqual({ sevenDayRejected: true, sevenDayReset: null });
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
it('should pick the first value when a header is an array', () => {
|
|
840
|
+
expect(
|
|
841
|
+
parseSevenDayRejection({
|
|
842
|
+
'anthropic-ratelimit-unified-7d-status': ['rejected', 'allowed'],
|
|
843
|
+
'anthropic-ratelimit-unified-7d-reset': ['1893456000'],
|
|
844
|
+
}),
|
|
845
|
+
).toEqual({ sevenDayRejected: true, sevenDayReset: 1893456000 });
|
|
846
|
+
});
|
|
847
|
+
});
|
|
848
|
+
|
|
702
849
|
describe('parseModelRateLimitsFromHeaders', () => {
|
|
703
850
|
it('should extract a rejected seven_day_sonnet limit from the per-model unified headers', () => {
|
|
704
851
|
expect(
|
|
@@ -915,6 +1062,7 @@ describe('RateLimitCache', () => {
|
|
|
915
1062
|
},
|
|
916
1063
|
subscriptionDisabled: snapshot?.subscriptionDisabled ?? false,
|
|
917
1064
|
unifiedRejected: snapshot?.unifiedRejected ?? false,
|
|
1065
|
+
fableRejected: false,
|
|
918
1066
|
},
|
|
919
1067
|
],
|
|
920
1068
|
Date.now() / 1000,
|
|
@@ -28,6 +28,8 @@ export interface RateLimitSnapshot {
|
|
|
28
28
|
|
|
29
29
|
export const PROXY_PORT = 8787;
|
|
30
30
|
|
|
31
|
+
export const FABLE_LIMIT_TYPE = 'seven_day_fable';
|
|
32
|
+
|
|
31
33
|
const HASH_ALGORITHM = 'sha256';
|
|
32
34
|
|
|
33
35
|
export const HEADERLESS_429_DEFAULT_COOLDOWN_SECONDS = 90;
|
|
@@ -36,6 +38,12 @@ export const HEADERLESS_429_MAX_COOLDOWN_SECONDS = 600;
|
|
|
36
38
|
|
|
37
39
|
export const PERMISSION_DISABLED_COOLDOWN_SECONDS = 3600;
|
|
38
40
|
|
|
41
|
+
const FIVE_HOUR_STATUS_HEADER = 'anthropic-ratelimit-unified-5h-status';
|
|
42
|
+
|
|
43
|
+
const SEVEN_DAY_STATUS_HEADER = 'anthropic-ratelimit-unified-7d-status';
|
|
44
|
+
|
|
45
|
+
const SEVEN_DAY_RESET_HEADER = 'anthropic-ratelimit-unified-7d-reset';
|
|
46
|
+
|
|
39
47
|
export const cacheDir = (): string => {
|
|
40
48
|
const base = process.env.XDG_CACHE_HOME ?? path.join(os.homedir(), '.cache');
|
|
41
49
|
return path.join(base, 'tdpm', 'ratelimit');
|
|
@@ -182,6 +190,51 @@ export const writeModelRateLimit = (
|
|
|
182
190
|
fs.writeFileSync(filePath, JSON.stringify(payload));
|
|
183
191
|
};
|
|
184
192
|
|
|
193
|
+
export const isFableModel = (modelName: string | null): boolean =>
|
|
194
|
+
(modelName ?? '').toLowerCase().includes('fable');
|
|
195
|
+
|
|
196
|
+
export interface SevenDayRejectionSignal {
|
|
197
|
+
sevenDayRejected: boolean;
|
|
198
|
+
sevenDayReset: number | null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const pickHeaderValue = (
|
|
202
|
+
headers: Record<string, string | string[] | undefined>,
|
|
203
|
+
key: string,
|
|
204
|
+
): string | undefined => {
|
|
205
|
+
const value = headers[key];
|
|
206
|
+
return Array.isArray(value) ? value[0] : value;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
export const parseSevenDayRejection = (
|
|
210
|
+
headers: Record<string, string | string[] | undefined>,
|
|
211
|
+
): SevenDayRejectionSignal => {
|
|
212
|
+
const status = pickHeaderValue(headers, SEVEN_DAY_STATUS_HEADER);
|
|
213
|
+
const resetRaw = pickHeaderValue(headers, SEVEN_DAY_RESET_HEADER);
|
|
214
|
+
const sevenDayReset =
|
|
215
|
+
resetRaw !== undefined && Number.isFinite(Number(resetRaw))
|
|
216
|
+
? Number(resetRaw)
|
|
217
|
+
: null;
|
|
218
|
+
return {
|
|
219
|
+
sevenDayRejected: status === 'rejected',
|
|
220
|
+
sevenDayReset,
|
|
221
|
+
};
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
export const writeFableRejection = (
|
|
225
|
+
token: string,
|
|
226
|
+
retryAfterSeconds: number | null,
|
|
227
|
+
sevenDayReset: number | null = null,
|
|
228
|
+
): void => {
|
|
229
|
+
const resetsAt =
|
|
230
|
+
sevenDayReset !== null && sevenDayReset > 0
|
|
231
|
+
? sevenDayReset
|
|
232
|
+
: cooldownEndFromRetryAfter(retryAfterSeconds, Date.now() / 1000);
|
|
233
|
+
writeModelRateLimit(token, {
|
|
234
|
+
[FABLE_LIMIT_TYPE]: { rejected: true, resetsAt },
|
|
235
|
+
});
|
|
236
|
+
};
|
|
237
|
+
|
|
185
238
|
export const writeSubscriptionDisabled = (
|
|
186
239
|
token: string,
|
|
187
240
|
baseDir: string = cacheDir(),
|
|
@@ -283,8 +336,8 @@ export const readRateLimit = (
|
|
|
283
336
|
return Number.isFinite(parsedValue) ? parsedValue : 0;
|
|
284
337
|
};
|
|
285
338
|
const status = headers['anthropic-ratelimit-unified-status'];
|
|
286
|
-
const fiveHourStatus = headers[
|
|
287
|
-
const sevenDayStatus = headers[
|
|
339
|
+
const fiveHourStatus = headers[FIVE_HOUR_STATUS_HEADER];
|
|
340
|
+
const sevenDayStatus = headers[SEVEN_DAY_STATUS_HEADER];
|
|
288
341
|
const overageDisabledReason =
|
|
289
342
|
headers['anthropic-ratelimit-unified-overage-disabled-reason'];
|
|
290
343
|
const unifiedRejected = status === 'rejected';
|
|
@@ -309,7 +362,7 @@ export const readRateLimit = (
|
|
|
309
362
|
fiveHourUtilization: num('anthropic-ratelimit-unified-5h-utilization'),
|
|
310
363
|
fiveHourReset: num('anthropic-ratelimit-unified-5h-reset'),
|
|
311
364
|
sevenDayUtilization: num('anthropic-ratelimit-unified-7d-utilization'),
|
|
312
|
-
sevenDayReset: num(
|
|
365
|
+
sevenDayReset: num(SEVEN_DAY_RESET_HEADER),
|
|
313
366
|
blocked:
|
|
314
367
|
status === 'blocked' ||
|
|
315
368
|
fiveHourStatus === 'blocked' ||
|
|
@@ -103,6 +103,7 @@ describe('startProxy', () => {
|
|
|
103
103
|
let proxyPort = 0;
|
|
104
104
|
let writeRateLimitSpy: jest.SpyInstance;
|
|
105
105
|
let writeModelRateLimitSpy: jest.SpyInstance;
|
|
106
|
+
let writeFableRejectionSpy: jest.SpyInstance;
|
|
106
107
|
|
|
107
108
|
const listen = (server: http.Server): Promise<number> =>
|
|
108
109
|
new Promise((resolve) => {
|
|
@@ -191,6 +192,9 @@ describe('startProxy', () => {
|
|
|
191
192
|
writeModelRateLimitSpy = jest
|
|
192
193
|
.spyOn(RateLimitCache, 'writeModelRateLimit')
|
|
193
194
|
.mockImplementation(() => undefined);
|
|
195
|
+
writeFableRejectionSpy = jest
|
|
196
|
+
.spyOn(RateLimitCache, 'writeFableRejection')
|
|
197
|
+
.mockImplementation(() => undefined);
|
|
194
198
|
|
|
195
199
|
proxyPort = await new Promise<number>((resolve) => {
|
|
196
200
|
const probe = http.createServer();
|
|
@@ -207,6 +211,7 @@ describe('startProxy', () => {
|
|
|
207
211
|
httpsRequestImpl = null;
|
|
208
212
|
writeRateLimitSpy.mockRestore();
|
|
209
213
|
writeModelRateLimitSpy.mockRestore();
|
|
214
|
+
writeFableRejectionSpy.mockRestore();
|
|
210
215
|
await closeServer(proxyServer);
|
|
211
216
|
await closeServer(upstreamServer);
|
|
212
217
|
});
|
|
@@ -308,6 +313,127 @@ describe('startProxy', () => {
|
|
|
308
313
|
);
|
|
309
314
|
});
|
|
310
315
|
|
|
316
|
+
it('should mark the fable weekly limit when a fable 429 carries a rejected 7-day status, using the 7-day reset', async () => {
|
|
317
|
+
const sevenDayReset = 1893456000;
|
|
318
|
+
upstreamHandler = (_request, response) => {
|
|
319
|
+
response.writeHead(429, {
|
|
320
|
+
'content-type': 'application/json',
|
|
321
|
+
'retry-after': '120',
|
|
322
|
+
'anthropic-ratelimit-unified-7d-status': 'rejected',
|
|
323
|
+
'anthropic-ratelimit-unified-7d-reset': String(sevenDayReset),
|
|
324
|
+
});
|
|
325
|
+
response.end('{"type":"error","error":{"type":"rate_limit_error"}}');
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const response = await requestThroughProxy(
|
|
329
|
+
'POST',
|
|
330
|
+
'/v1/messages',
|
|
331
|
+
JSON.stringify({ model: 'claude-fable-5', messages: [] }),
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
expect(response.statusCode).toBe(429);
|
|
335
|
+
expect(writeFableRejectionSpy).toHaveBeenCalledTimes(1);
|
|
336
|
+
expect(writeFableRejectionSpy).toHaveBeenCalledWith(
|
|
337
|
+
TOKEN,
|
|
338
|
+
120,
|
|
339
|
+
sevenDayReset,
|
|
340
|
+
);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('should fall back to retry-after for the reset when a rejected 7-day status carries no 7-day reset header', async () => {
|
|
344
|
+
upstreamHandler = (_request, response) => {
|
|
345
|
+
response.writeHead(429, {
|
|
346
|
+
'content-type': 'application/json',
|
|
347
|
+
'retry-after': '120',
|
|
348
|
+
'anthropic-ratelimit-unified-7d-status': 'rejected',
|
|
349
|
+
});
|
|
350
|
+
response.end('{"type":"error","error":{"type":"rate_limit_error"}}');
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const response = await requestThroughProxy(
|
|
354
|
+
'POST',
|
|
355
|
+
'/v1/messages',
|
|
356
|
+
JSON.stringify({ model: 'claude-fable-5', messages: [] }),
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
expect(response.statusCode).toBe(429);
|
|
360
|
+
expect(writeFableRejectionSpy).toHaveBeenCalledTimes(1);
|
|
361
|
+
expect(writeFableRejectionSpy).toHaveBeenCalledWith(TOKEN, 120, null);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('should not mark the fable weekly limit when a fable 429 is a 5-hour rejection without a 7-day rejection', async () => {
|
|
365
|
+
upstreamHandler = (_request, response) => {
|
|
366
|
+
response.writeHead(429, {
|
|
367
|
+
'content-type': 'application/json',
|
|
368
|
+
'retry-after': '120',
|
|
369
|
+
'anthropic-ratelimit-unified-5h-status': 'rejected',
|
|
370
|
+
'anthropic-ratelimit-unified-7d-status': 'allowed',
|
|
371
|
+
});
|
|
372
|
+
response.end('{"type":"error","error":{"type":"rate_limit_error"}}');
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
const response = await requestThroughProxy(
|
|
376
|
+
'POST',
|
|
377
|
+
'/v1/messages',
|
|
378
|
+
JSON.stringify({ model: 'claude-fable-5', messages: [] }),
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
expect(response.statusCode).toBe(429);
|
|
382
|
+
expect(writeFableRejectionSpy).not.toHaveBeenCalled();
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('should not mark the fable weekly limit when a fable 429 carries no rate-limit rejection headers', async () => {
|
|
386
|
+
upstreamHandler = (_request, response) => {
|
|
387
|
+
response.writeHead(429, { 'content-type': 'application/json' });
|
|
388
|
+
response.end('{"type":"error","error":{"type":"rate_limit_error"}}');
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
const response = await requestThroughProxy(
|
|
392
|
+
'POST',
|
|
393
|
+
'/v1/messages',
|
|
394
|
+
JSON.stringify({ model: 'claude-fable-5', messages: [] }),
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
expect(response.statusCode).toBe(429);
|
|
398
|
+
expect(writeFableRejectionSpy).not.toHaveBeenCalled();
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it('should not mark the fable weekly limit when a non-fable request is rejected with a 7-day-rejected 429', async () => {
|
|
402
|
+
upstreamHandler = (_request, response) => {
|
|
403
|
+
response.writeHead(429, {
|
|
404
|
+
'content-type': 'application/json',
|
|
405
|
+
'anthropic-ratelimit-unified-7d-status': 'rejected',
|
|
406
|
+
'anthropic-ratelimit-unified-7d-reset': '1893456000',
|
|
407
|
+
});
|
|
408
|
+
response.end('{"type":"error","error":{"type":"rate_limit_error"}}');
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
const response = await requestThroughProxy(
|
|
412
|
+
'POST',
|
|
413
|
+
'/v1/messages',
|
|
414
|
+
JSON.stringify({ model: 'claude-sonnet-4-6', messages: [] }),
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
expect(response.statusCode).toBe(429);
|
|
418
|
+
expect(writeFableRejectionSpy).not.toHaveBeenCalled();
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it('should not mark the fable weekly limit when a fable request succeeds', async () => {
|
|
422
|
+
upstreamHandler = (_request, response) => {
|
|
423
|
+
response.writeHead(200, { 'content-type': 'application/json' });
|
|
424
|
+
response.end('{"model":"claude-fable-5"}');
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
const response = await requestThroughProxy(
|
|
428
|
+
'POST',
|
|
429
|
+
'/v1/messages',
|
|
430
|
+
JSON.stringify({ model: 'claude-fable-5', messages: [] }),
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
expect(response.statusCode).toBe(200);
|
|
434
|
+
expect(writeFableRejectionSpy).not.toHaveBeenCalled();
|
|
435
|
+
});
|
|
436
|
+
|
|
311
437
|
it('should forward non-SSE responses without crashing', async () => {
|
|
312
438
|
upstreamHandler = (_request, response) => {
|
|
313
439
|
response.writeHead(200, { 'content-type': 'application/json' });
|
|
@@ -3,7 +3,10 @@ import * as https from 'https';
|
|
|
3
3
|
import {
|
|
4
4
|
PROXY_PORT,
|
|
5
5
|
hashToken,
|
|
6
|
+
isFableModel,
|
|
6
7
|
parseModelRateLimitsFromBody,
|
|
8
|
+
parseSevenDayRejection,
|
|
9
|
+
writeFableRejection,
|
|
7
10
|
writeModelRateLimit,
|
|
8
11
|
writeRateLimit,
|
|
9
12
|
writeSubscriptionDisabled,
|
|
@@ -46,12 +49,49 @@ const isPermissionError = (body: string): boolean => {
|
|
|
46
49
|
return error.type === 'permission_error';
|
|
47
50
|
};
|
|
48
51
|
|
|
52
|
+
const matchModelInBody = (requestBody: string): string | null => {
|
|
53
|
+
const match = requestBody.match(/"model"\s*:\s*"([^"]+)"/);
|
|
54
|
+
return match !== null ? match[1] : null;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const extractRequestModel = (requestBody: string): string | null => {
|
|
58
|
+
try {
|
|
59
|
+
const parsed: unknown = JSON.parse(requestBody);
|
|
60
|
+
if (isRecord(parsed) && typeof parsed.model === 'string') {
|
|
61
|
+
return parsed.model;
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
} catch {
|
|
65
|
+
return matchModelInBody(requestBody);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const parseRetryAfterSeconds = (
|
|
70
|
+
headers: http.IncomingHttpHeaders,
|
|
71
|
+
): number | null => {
|
|
72
|
+
const raw = headers['retry-after'];
|
|
73
|
+
const value =
|
|
74
|
+
typeof raw === 'string' ? raw : Array.isArray(raw) ? raw[0] : null;
|
|
75
|
+
if (typeof value !== 'string') return null;
|
|
76
|
+
const parsed = Number(value);
|
|
77
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
78
|
+
};
|
|
79
|
+
|
|
49
80
|
const startProxy = (
|
|
50
81
|
port: number,
|
|
51
82
|
claudeMessageResponseRepository: ClaudeMessageResponseRepository | null = null,
|
|
52
83
|
): http.Server => {
|
|
53
84
|
const server = http.createServer((clientRequest, clientResponse) => {
|
|
54
85
|
const token = extractToken(clientRequest.headers['authorization']);
|
|
86
|
+
const requestChunks: Uint8Array[] = [];
|
|
87
|
+
let requestBytes = 0;
|
|
88
|
+
if (token !== null) {
|
|
89
|
+
clientRequest.on('data', (chunk: Buffer) => {
|
|
90
|
+
if (requestBytes >= MAX_INSPECTED_BODY_BYTES) return;
|
|
91
|
+
requestChunks.push(new Uint8Array(chunk));
|
|
92
|
+
requestBytes += chunk.length;
|
|
93
|
+
});
|
|
94
|
+
}
|
|
55
95
|
const upstreamHeaders: Record<string, string | string[] | undefined> = {
|
|
56
96
|
...clientRequest.headers,
|
|
57
97
|
host: UPSTREAM_HOST,
|
|
@@ -105,6 +145,26 @@ const startProxy = (
|
|
|
105
145
|
);
|
|
106
146
|
}
|
|
107
147
|
}
|
|
148
|
+
if (upstreamResponse.statusCode === 429) {
|
|
149
|
+
try {
|
|
150
|
+
const requestModel = extractRequestModel(
|
|
151
|
+
Buffer.concat(requestChunks).toString('utf8'),
|
|
152
|
+
);
|
|
153
|
+
if (isFableModel(requestModel)) {
|
|
154
|
+
const { sevenDayRejected, sevenDayReset } =
|
|
155
|
+
parseSevenDayRejection(upstreamResponse.headers);
|
|
156
|
+
if (sevenDayRejected) {
|
|
157
|
+
writeFableRejection(
|
|
158
|
+
token,
|
|
159
|
+
parseRetryAfterSeconds(upstreamResponse.headers),
|
|
160
|
+
sevenDayReset,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
} catch (error) {
|
|
165
|
+
console.error('Failed to write fable rejection cache:', error);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
108
168
|
if (claudeMessageResponseRepository !== null) {
|
|
109
169
|
try {
|
|
110
170
|
const response = parseClaudeMessageResponse(
|