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.
Files changed (62) hide show
  1. package/.github/workflows/commit-lint.yml +1 -1
  2. package/CHANGELOG.md +14 -0
  3. package/README.md +5 -3
  4. package/bin/adapter/entry-points/cli/projectConfig.js +6 -0
  5. package/bin/adapter/entry-points/cli/projectConfig.js.map +1 -1
  6. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +29 -16
  7. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  8. package/bin/adapter/entry-points/handlers/InTmuxByHumanSessionTokenCountHandler.js +1 -0
  9. package/bin/adapter/entry-points/handlers/InTmuxByHumanSessionTokenCountHandler.js.map +1 -1
  10. package/bin/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.js +5 -0
  11. package/bin/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.js.map +1 -1
  12. package/bin/adapter/entry-points/handlers/OauthTokenSelectHandler.js +5 -0
  13. package/bin/adapter/entry-points/handlers/OauthTokenSelectHandler.js.map +1 -1
  14. package/bin/adapter/entry-points/handlers/tokenStatusWriter.js +1 -0
  15. package/bin/adapter/entry-points/handlers/tokenStatusWriter.js.map +1 -1
  16. package/bin/adapter/proxy/RateLimitCache.js +35 -4
  17. package/bin/adapter/proxy/RateLimitCache.js.map +1 -1
  18. package/bin/adapter/proxy/proxyEntry.js +48 -0
  19. package/bin/adapter/proxy/proxyEntry.js.map +1 -1
  20. package/bin/domain/usecases/AssignNoAssigneeIssueToManagerUseCase.js +7 -0
  21. package/bin/domain/usecases/AssignNoAssigneeIssueToManagerUseCase.js.map +1 -1
  22. package/bin/domain/usecases/HandleScheduledEventUseCase.js +1 -0
  23. package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
  24. package/bin/domain/usecases/OauthTokenSelectUseCase.js +5 -2
  25. package/bin/domain/usecases/OauthTokenSelectUseCase.js.map +1 -1
  26. package/package.json +1 -1
  27. package/src/adapter/entry-points/cli/projectConfig.ts +14 -0
  28. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.test.ts +66 -0
  29. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +5 -1
  30. package/src/adapter/entry-points/handlers/InTmuxByHumanSessionTokenCountHandler.ts +1 -0
  31. package/src/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.test.ts +49 -1
  32. package/src/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.ts +7 -1
  33. package/src/adapter/entry-points/handlers/OauthTokenSelectHandler.test.ts +65 -1
  34. package/src/adapter/entry-points/handlers/OauthTokenSelectHandler.ts +11 -1
  35. package/src/adapter/entry-points/handlers/tokenStatusWriter.ts +1 -0
  36. package/src/adapter/proxy/RateLimitCache.test.ts +148 -0
  37. package/src/adapter/proxy/RateLimitCache.ts +56 -3
  38. package/src/adapter/proxy/proxyEntry.test.ts +126 -0
  39. package/src/adapter/proxy/proxyEntry.ts +60 -0
  40. package/src/domain/usecases/AssignNoAssigneeIssueToManagerUseCase.test.ts +95 -0
  41. package/src/domain/usecases/AssignNoAssigneeIssueToManagerUseCase.ts +9 -0
  42. package/src/domain/usecases/HandleScheduledEventUseCase.ts +2 -0
  43. package/src/domain/usecases/InTmuxByHumanSessionTokenCountUseCase.test.ts +1 -0
  44. package/src/domain/usecases/LiveSessionOauthTokenSelectUseCase.test.ts +2 -0
  45. package/src/domain/usecases/OauthTokenSelectUseCase.test.ts +25 -0
  46. package/src/domain/usecases/OauthTokenSelectUseCase.ts +6 -0
  47. package/types/adapter/entry-points/cli/projectConfig.d.ts +1 -0
  48. package/types/adapter/entry-points/cli/projectConfig.d.ts.map +1 -1
  49. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  50. package/types/adapter/entry-points/handlers/InTmuxByHumanSessionTokenCountHandler.d.ts.map +1 -1
  51. package/types/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.d.ts.map +1 -1
  52. package/types/adapter/entry-points/handlers/OauthTokenSelectHandler.d.ts.map +1 -1
  53. package/types/adapter/entry-points/handlers/tokenStatusWriter.d.ts.map +1 -1
  54. package/types/adapter/proxy/RateLimitCache.d.ts +8 -0
  55. package/types/adapter/proxy/RateLimitCache.d.ts.map +1 -1
  56. package/types/adapter/proxy/proxyEntry.d.ts.map +1 -1
  57. package/types/domain/usecases/AssignNoAssigneeIssueToManagerUseCase.d.ts +1 -0
  58. package/types/domain/usecases/AssignNoAssigneeIssueToManagerUseCase.d.ts.map +1 -1
  59. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +1 -0
  60. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
  61. package/types/domain/usecases/OauthTokenSelectUseCase.d.ts +1 -0
  62. 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 { cacheDir, readRateLimit } from '../../proxy/RateLimitCache';
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['anthropic-ratelimit-unified-5h-status'];
287
- const sevenDayStatus = headers['anthropic-ratelimit-unified-7d-status'];
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('anthropic-ratelimit-unified-7d-reset'),
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(