github-issue-tower-defence-management 1.88.1 → 1.90.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 (47) hide show
  1. package/.github/CODEOWNERS +1 -2
  2. package/CHANGELOG.md +14 -0
  3. package/README.md +13 -2
  4. package/bin/adapter/entry-points/cli/index.js +21 -0
  5. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  6. package/bin/adapter/entry-points/console/consoleServer.js +40 -13
  7. package/bin/adapter/entry-points/console/consoleServer.js.map +1 -1
  8. package/bin/adapter/entry-points/console/ui-dist/assets/{index-DcOZ02ON.js → index-DDjYPXRT.js} +10 -10
  9. package/bin/adapter/entry-points/console/ui-dist/assets/index-DHlBLm7d.css +1 -0
  10. package/bin/adapter/entry-points/console/ui-dist/index.html +2 -2
  11. package/bin/adapter/entry-points/handlers/OauthTokenSelectHandler.js +97 -0
  12. package/bin/adapter/entry-points/handlers/OauthTokenSelectHandler.js.map +1 -0
  13. package/bin/adapter/proxy/RateLimitCache.js +3 -3
  14. package/bin/adapter/proxy/RateLimitCache.js.map +1 -1
  15. package/bin/domain/usecases/OauthTokenSelectUseCase.js +87 -0
  16. package/bin/domain/usecases/OauthTokenSelectUseCase.js.map +1 -0
  17. package/package.json +1 -1
  18. package/src/adapter/entry-points/cli/index.ts +38 -0
  19. package/src/adapter/entry-points/console/consoleServer.test.ts +81 -0
  20. package/src/adapter/entry-points/console/consoleServer.ts +48 -16
  21. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleProjectHeader.stories.tsx +29 -0
  22. package/src/adapter/entry-points/console/ui/src/features/console/components/ConsoleProjectHeader.tsx +14 -0
  23. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleList.ts +17 -4
  24. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsolePjcode.test.ts +24 -0
  25. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsolePjcode.ts +17 -0
  26. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +5 -1
  27. package/src/adapter/entry-points/console/ui/tsconfig.json +2 -1
  28. package/src/adapter/entry-points/console/ui/vite.config.ts +1 -1
  29. package/src/adapter/entry-points/console/ui-dist/assets/{index-DcOZ02ON.js → index-DDjYPXRT.js} +10 -10
  30. package/src/adapter/entry-points/console/ui-dist/assets/index-DHlBLm7d.css +1 -0
  31. package/src/adapter/entry-points/console/ui-dist/index.html +2 -2
  32. package/src/adapter/entry-points/handlers/OauthTokenSelectHandler.test.ts +204 -0
  33. package/src/adapter/entry-points/handlers/OauthTokenSelectHandler.ts +132 -0
  34. package/src/adapter/proxy/RateLimitCache.ts +9 -4
  35. package/src/domain/usecases/OauthTokenSelectUseCase.test.ts +179 -0
  36. package/src/domain/usecases/OauthTokenSelectUseCase.ts +158 -0
  37. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  38. package/types/adapter/entry-points/console/consoleServer.d.ts +1 -0
  39. package/types/adapter/entry-points/console/consoleServer.d.ts.map +1 -1
  40. package/types/adapter/entry-points/handlers/OauthTokenSelectHandler.d.ts +20 -0
  41. package/types/adapter/entry-points/handlers/OauthTokenSelectHandler.d.ts.map +1 -0
  42. package/types/adapter/proxy/RateLimitCache.d.ts +2 -2
  43. package/types/adapter/proxy/RateLimitCache.d.ts.map +1 -1
  44. package/types/domain/usecases/OauthTokenSelectUseCase.d.ts +35 -0
  45. package/types/domain/usecases/OauthTokenSelectUseCase.d.ts.map +1 -0
  46. package/bin/adapter/entry-points/console/ui-dist/assets/index-DFxrSRH4.css +0 -1
  47. package/src/adapter/entry-points/console/ui-dist/assets/index-DFxrSRH4.css +0 -1
@@ -0,0 +1 @@
1
+ /*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-outline-style:solid;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--spacing:.25rem;--container-3xl:48rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height: 1.5 ;--font-weight-medium:500;--font-weight-semibold:600;--radius-md:.375rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-border:#e5e5e5;--color-input:#e5e5e5;--color-ring:#0a0a0a;--color-background:#fff;--color-foreground:#0a0a0a;--color-primary:#171717;--color-primary-foreground:#fafafa;--color-secondary:#f5f5f5;--color-secondary-foreground:#171717;--color-muted-foreground:#737373;--color-accent:#f5f5f5;--color-accent-foreground:#171717;--color-destructive:#ef4444}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){-webkit-appearance:button;-moz-appearance:button;appearance:button}::file-selector-button{-webkit-appearance:button;-moz-appearance:button;appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.container{width:100%}@media(min-width:40rem){.container{max-width:40rem}}@media(min-width:48rem){.container{max-width:48rem}}@media(min-width:64rem){.container{max-width:64rem}}@media(min-width:80rem){.container{max-width:80rem}}@media(min-width:96rem){.container{max-width:96rem}}.mx-auto{margin-inline:auto}.flex{display:flex}.inline-flex{display:inline-flex}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.max-w-3xl{max-width:var(--container-3xl)}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.justify-center{justify-content:center}.gap-1{gap:calc(var(--spacing) * 1)}.gap-2{gap:calc(var(--spacing) * 2)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-border>:not(:last-child)){border-color:var(--color-border)}.rounded-md{border-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-border{border-color:var(--color-border)}.border-input{border-color:var(--color-input)}.border-transparent{border-color:#0000}.bg-background{background-color:var(--color-background)}.bg-primary{background-color:var(--color-primary)}.bg-secondary{background-color:var(--color-secondary)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-8{padding-inline:calc(var(--spacing) * 8)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-2{padding-block:calc(var(--spacing) * 2)}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.whitespace-nowrap{white-space:nowrap}.text-destructive{color:var(--color-destructive)}.text-foreground{color:var(--color-foreground)}.text-muted-foreground{color:var(--color-muted-foreground)}.text-primary-foreground{color:var(--color-primary-foreground)}.text-secondary-foreground{color:var(--color-secondary-foreground)}.underline-offset-2{text-underline-offset:2px}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media(hover:hover){.hover\:bg-accent:hover{background-color:var(--color-accent)}.hover\:bg-primary\/90:hover{background-color:#171717e6}@supports (color:color-mix(in lab,red,red)){.hover\:bg-primary\/90:hover{background-color:color-mix(in oklab,var(--color-primary) 90%,transparent)}}.hover\:bg-secondary\/80:hover{background-color:#f5f5f5cc}@supports (color:color-mix(in lab,red,red)){.hover\:bg-secondary\/80:hover{background-color:color-mix(in oklab,var(--color-secondary) 80%,transparent)}}.hover\:text-accent-foreground:hover{color:var(--color-accent-foreground)}.hover\:underline:hover{text-decoration-line:underline}}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-ring:focus-visible{--tw-ring-color:var(--color-ring)}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:opacity-50:disabled{opacity:.5}}body{background-color:var(--color-background);color:var(--color-foreground);margin:0;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}
@@ -4,8 +4,8 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>TDPM Console</title>
7
- <script type="module" crossorigin src="./assets/index-DcOZ02ON.js"></script>
8
- <link rel="stylesheet" crossorigin href="./assets/index-DFxrSRH4.css">
7
+ <script type="module" crossorigin src="/assets/index-DDjYPXRT.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-DHlBLm7d.css">
9
9
  </head>
10
10
  <body>
11
11
  <div id="root"></div>
@@ -0,0 +1,204 @@
1
+ import * as fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import { hashToken } from '../../proxy/RateLimitCache';
5
+ import {
6
+ OauthTokenSelectHandler,
7
+ resolveCacheDirectory,
8
+ resolveTokenListJsonPath,
9
+ } from './OauthTokenSelectHandler';
10
+
11
+ const NOW = 2_000_000;
12
+ const HOUR = 3600;
13
+ const DAY = 86400;
14
+
15
+ type FakeHeaders = {
16
+ fiveHourUtilization: number;
17
+ fiveHourReset: number;
18
+ sevenDayUtilization: number;
19
+ sevenDayReset: number;
20
+ };
21
+
22
+ describe('OauthTokenSelectHandler', () => {
23
+ let tempDir: string;
24
+ let cacheDirectory: string;
25
+ let tokenListPath: string;
26
+ const originalTokenListEnv =
27
+ process.env.CLAUDE_CODE_OAUTH_TOKEN_LIST_JSON_PATH;
28
+ const originalCacheEnv = process.env.TDPM_RATELIMIT_CACHE_DIR;
29
+
30
+ beforeEach(() => {
31
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'select-token-'));
32
+ cacheDirectory = path.join(tempDir, 'cache');
33
+ fs.mkdirSync(cacheDirectory, { recursive: true });
34
+ tokenListPath = path.join(tempDir, 'tokens.json');
35
+ delete process.env.CLAUDE_CODE_OAUTH_TOKEN_LIST_JSON_PATH;
36
+ delete process.env.TDPM_RATELIMIT_CACHE_DIR;
37
+ });
38
+
39
+ afterEach(() => {
40
+ if (originalTokenListEnv === undefined) {
41
+ delete process.env.CLAUDE_CODE_OAUTH_TOKEN_LIST_JSON_PATH;
42
+ } else {
43
+ process.env.CLAUDE_CODE_OAUTH_TOKEN_LIST_JSON_PATH = originalTokenListEnv;
44
+ }
45
+ if (originalCacheEnv === undefined) {
46
+ delete process.env.TDPM_RATELIMIT_CACHE_DIR;
47
+ } else {
48
+ process.env.TDPM_RATELIMIT_CACHE_DIR = originalCacheEnv;
49
+ }
50
+ fs.rmSync(tempDir, { recursive: true, force: true });
51
+ });
52
+
53
+ const writeTokenList = (entries: { name: string; token: string }[]): void => {
54
+ fs.writeFileSync(tokenListPath, JSON.stringify(entries));
55
+ };
56
+
57
+ const writeCache = (token: string, headers: FakeHeaders): void => {
58
+ const payload = {
59
+ ts: NOW,
60
+ headers: {
61
+ 'anthropic-ratelimit-unified-status': 'allowed',
62
+ 'anthropic-ratelimit-unified-5h-status': 'allowed',
63
+ 'anthropic-ratelimit-unified-5h-reset': String(headers.fiveHourReset),
64
+ 'anthropic-ratelimit-unified-5h-utilization': String(
65
+ headers.fiveHourUtilization,
66
+ ),
67
+ 'anthropic-ratelimit-unified-7d-status': 'allowed',
68
+ 'anthropic-ratelimit-unified-7d-reset': String(headers.sevenDayReset),
69
+ 'anthropic-ratelimit-unified-7d-utilization': String(
70
+ headers.sevenDayUtilization,
71
+ ),
72
+ },
73
+ modelWeeklyLimits: {},
74
+ };
75
+ fs.writeFileSync(
76
+ path.join(cacheDirectory, `${hashToken(token)}.json`),
77
+ JSON.stringify(payload),
78
+ );
79
+ };
80
+
81
+ it('selects the eligible token with the soonest 7d reset', () => {
82
+ writeTokenList([
83
+ { name: 'far', token: 'fake-far' },
84
+ { name: 'soon', token: 'fake-soon' },
85
+ ]);
86
+ writeCache('fake-far', {
87
+ fiveHourUtilization: 0.1,
88
+ fiveHourReset: NOW + HOUR,
89
+ sevenDayUtilization: 0.1,
90
+ sevenDayReset: NOW + 6 * DAY,
91
+ });
92
+ writeCache('fake-soon', {
93
+ fiveHourUtilization: 0.1,
94
+ fiveHourReset: NOW + HOUR,
95
+ sevenDayUtilization: 0.1,
96
+ sevenDayReset: NOW + 2 * DAY,
97
+ });
98
+
99
+ const handler = new OauthTokenSelectHandler();
100
+ const output = handler.handle({
101
+ tokenListJsonPath: tokenListPath,
102
+ cacheDirectory,
103
+ nowEpochSeconds: NOW,
104
+ });
105
+
106
+ expect(output.selectedName).toBe('soon');
107
+ expect(output.selectedToken).toBe('fake-soon');
108
+ });
109
+
110
+ it('treats a token with no cache file as fully free', () => {
111
+ writeTokenList([{ name: 'fresh', token: 'fake-fresh' }]);
112
+
113
+ const handler = new OauthTokenSelectHandler();
114
+ const output = handler.handle({
115
+ tokenListJsonPath: tokenListPath,
116
+ cacheDirectory,
117
+ nowEpochSeconds: NOW,
118
+ });
119
+
120
+ expect(output.selectedName).toBe('fresh');
121
+ });
122
+
123
+ it('returns null and a diagnostic when no token passes the filter', () => {
124
+ writeTokenList([{ name: 'busy', token: 'fake-busy' }]);
125
+ writeCache('fake-busy', {
126
+ fiveHourUtilization: 0.9,
127
+ fiveHourReset: NOW + HOUR,
128
+ sevenDayUtilization: 0.1,
129
+ sevenDayReset: NOW + DAY,
130
+ });
131
+
132
+ const handler = new OauthTokenSelectHandler();
133
+ const output = handler.handle({
134
+ tokenListJsonPath: tokenListPath,
135
+ cacheDirectory,
136
+ nowEpochSeconds: NOW,
137
+ });
138
+
139
+ expect(output.selectedToken).toBeNull();
140
+ expect(output.diagnostics.join('\n')).toContain(
141
+ 'No eligible token passed the rate-limit filter.',
142
+ );
143
+ });
144
+
145
+ it('returns a diagnostic when no token list path is resolvable', () => {
146
+ const handler = new OauthTokenSelectHandler();
147
+ const output = handler.handle({
148
+ tokenListJsonPath: null,
149
+ cacheDirectory,
150
+ nowEpochSeconds: NOW,
151
+ });
152
+
153
+ expect(output.selectedToken).toBeNull();
154
+ expect(output.diagnostics.join('\n')).toContain('No token list path');
155
+ });
156
+
157
+ it('returns a diagnostic when the token list file has no usable entries', () => {
158
+ fs.writeFileSync(tokenListPath, JSON.stringify([]));
159
+
160
+ const handler = new OauthTokenSelectHandler();
161
+ const output = handler.handle({
162
+ tokenListJsonPath: tokenListPath,
163
+ cacheDirectory,
164
+ nowEpochSeconds: NOW,
165
+ });
166
+
167
+ expect(output.selectedToken).toBeNull();
168
+ expect(output.diagnostics.join('\n')).toContain('No usable token entries');
169
+ });
170
+
171
+ describe('resolveTokenListJsonPath', () => {
172
+ it('prefers the explicit path over the environment variable', () => {
173
+ process.env.CLAUDE_CODE_OAUTH_TOKEN_LIST_JSON_PATH = '/from/env.json';
174
+ expect(resolveTokenListJsonPath('/explicit.json')).toBe('/explicit.json');
175
+ });
176
+
177
+ it('falls back to the environment variable when no explicit path is given', () => {
178
+ process.env.CLAUDE_CODE_OAUTH_TOKEN_LIST_JSON_PATH = '/from/env.json';
179
+ expect(resolveTokenListJsonPath(null)).toBe('/from/env.json');
180
+ });
181
+
182
+ it('returns null when neither source provides a path', () => {
183
+ expect(resolveTokenListJsonPath(null)).toBeNull();
184
+ });
185
+ });
186
+
187
+ describe('resolveCacheDirectory', () => {
188
+ it('prefers the explicit directory over the environment variable', () => {
189
+ process.env.TDPM_RATELIMIT_CACHE_DIR = '/from/env';
190
+ expect(resolveCacheDirectory('/explicit')).toBe('/explicit');
191
+ });
192
+
193
+ it('falls back to the environment variable when no explicit directory is given', () => {
194
+ process.env.TDPM_RATELIMIT_CACHE_DIR = '/from/env';
195
+ expect(resolveCacheDirectory(null)).toBe('/from/env');
196
+ });
197
+
198
+ it('falls back to the default tdpm cache directory', () => {
199
+ expect(resolveCacheDirectory(null)).toContain(
200
+ path.join('tdpm', 'ratelimit'),
201
+ );
202
+ });
203
+ });
204
+ });
@@ -0,0 +1,132 @@
1
+ import {
2
+ OauthTokenCandidate,
3
+ OauthTokenSelectResult,
4
+ OauthTokenSelectUseCase,
5
+ } from '../../../domain/usecases/OauthTokenSelectUseCase';
6
+ import { cacheDir, readRateLimit } from '../../proxy/RateLimitCache';
7
+ import { loadTokenEntries } from '../../proxy/TokenListLoader';
8
+
9
+ export type OauthTokenSelectHandlerInput = {
10
+ tokenListJsonPath: string | null;
11
+ cacheDirectory: string | null;
12
+ nowEpochSeconds: number;
13
+ };
14
+
15
+ export type OauthTokenSelectHandlerOutput = {
16
+ selectedToken: string | null;
17
+ selectedName: string | null;
18
+ diagnostics: string[];
19
+ };
20
+
21
+ const DEFAULT_TOKEN_LIST_PATH_ENV = 'CLAUDE_CODE_OAUTH_TOKEN_LIST_JSON_PATH';
22
+ const DEFAULT_CACHE_DIRECTORY_ENV = 'TDPM_RATELIMIT_CACHE_DIR';
23
+
24
+ export const resolveTokenListJsonPath = (
25
+ explicitPath: string | null,
26
+ ): string | null => {
27
+ if (explicitPath !== null && explicitPath.length > 0) {
28
+ return explicitPath;
29
+ }
30
+ const fromEnv = process.env[DEFAULT_TOKEN_LIST_PATH_ENV];
31
+ if (fromEnv !== undefined && fromEnv.length > 0) {
32
+ return fromEnv;
33
+ }
34
+ return null;
35
+ };
36
+
37
+ export const resolveCacheDirectory = (
38
+ explicitDirectory: string | null,
39
+ ): string => {
40
+ if (explicitDirectory !== null && explicitDirectory.length > 0) {
41
+ return explicitDirectory;
42
+ }
43
+ const fromEnv = process.env[DEFAULT_CACHE_DIRECTORY_ENV];
44
+ if (fromEnv !== undefined && fromEnv.length > 0) {
45
+ return fromEnv;
46
+ }
47
+ return cacheDir();
48
+ };
49
+
50
+ export class OauthTokenSelectHandler {
51
+ constructor(
52
+ private readonly useCase: OauthTokenSelectUseCase = new OauthTokenSelectUseCase(),
53
+ ) {}
54
+
55
+ handle = (
56
+ input: OauthTokenSelectHandlerInput,
57
+ ): OauthTokenSelectHandlerOutput => {
58
+ const tokenListJsonPath = resolveTokenListJsonPath(input.tokenListJsonPath);
59
+ if (tokenListJsonPath === null) {
60
+ return {
61
+ selectedToken: null,
62
+ selectedName: null,
63
+ diagnostics: [
64
+ `No token list path provided. Pass --tokenListJsonPath or set ${DEFAULT_TOKEN_LIST_PATH_ENV}.`,
65
+ ],
66
+ };
67
+ }
68
+
69
+ const entries = loadTokenEntries(tokenListJsonPath);
70
+ if (entries === null) {
71
+ return {
72
+ selectedToken: null,
73
+ selectedName: null,
74
+ diagnostics: [
75
+ `No usable token entries loaded from ${tokenListJsonPath}.`,
76
+ ],
77
+ };
78
+ }
79
+
80
+ const cacheDirectory = resolveCacheDirectory(input.cacheDirectory);
81
+
82
+ const candidates: OauthTokenCandidate[] = entries.map(({ name, token }) => {
83
+ const snapshot = readRateLimit(token, cacheDirectory);
84
+ return {
85
+ name,
86
+ token,
87
+ snapshot:
88
+ snapshot === null
89
+ ? null
90
+ : {
91
+ fiveHourUtilization: snapshot.fiveHourUtilization,
92
+ fiveHourReset: snapshot.fiveHourReset,
93
+ sevenDayUtilization: snapshot.sevenDayUtilization,
94
+ sevenDayReset: snapshot.sevenDayReset,
95
+ },
96
+ };
97
+ });
98
+
99
+ const result = this.useCase.run(candidates, input.nowEpochSeconds);
100
+
101
+ return {
102
+ selectedToken: result.selected?.token ?? null,
103
+ selectedName: result.selected?.name ?? null,
104
+ diagnostics: this.formatDiagnostics(result, input.nowEpochSeconds),
105
+ };
106
+ };
107
+
108
+ private formatDiagnostics = (
109
+ result: OauthTokenSelectResult,
110
+ nowEpochSeconds: number,
111
+ ): string[] => {
112
+ const lines = result.metrics.map((metric) => {
113
+ const secondsUntilSevenDayEnd = Math.round(
114
+ metric.sevenDayEndEpoch - nowEpochSeconds,
115
+ );
116
+ const status = metric.eligible
117
+ ? 'eligible'
118
+ : `excluded (${metric.exclusionReason})`;
119
+ return `${metric.name}: 5h ${Math.round(metric.fiveHourFreeRatio * 100)}% free, 7d ${Math.round(metric.sevenDayFreeRatio * 100)}% free, 7d-end in ${secondsUntilSevenDayEnd}s -> ${status}`;
120
+ });
121
+
122
+ if (result.selected === null) {
123
+ lines.push('No eligible token passed the rate-limit filter.');
124
+ } else {
125
+ lines.push(
126
+ `Selected ${result.selected.name} (soonest 7d reset among eligible tokens).`,
127
+ );
128
+ }
129
+
130
+ return lines;
131
+ };
132
+ }
@@ -39,8 +39,10 @@ export const cacheDir = (): string => {
39
39
  export const hashToken = (token: string): string =>
40
40
  crypto.createHash(HASH_ALGORITHM).update(token).digest('hex');
41
41
 
42
- export const cachePathForToken = (token: string): string =>
43
- path.join(cacheDir(), `${hashToken(token)}.json`);
42
+ export const cachePathForToken = (
43
+ token: string,
44
+ baseDir: string = cacheDir(),
45
+ ): string => path.join(baseDir, `${hashToken(token)}.json`);
44
46
 
45
47
  const isRecord = (value: unknown): value is Record<string, unknown> =>
46
48
  value !== null && typeof value === 'object' && !Array.isArray(value);
@@ -222,8 +224,11 @@ export const parseModelRateLimitsFromHeaders = (
222
224
  return result;
223
225
  };
224
226
 
225
- export const readRateLimit = (token: string): RateLimitSnapshot | null => {
226
- const filePath = cachePathForToken(token);
227
+ export const readRateLimit = (
228
+ token: string,
229
+ baseDir: string = cacheDir(),
230
+ ): RateLimitSnapshot | null => {
231
+ const filePath = cachePathForToken(token, baseDir);
227
232
  if (!fs.existsSync(filePath)) return null;
228
233
  try {
229
234
  const raw = fs.readFileSync(filePath, 'utf8');
@@ -0,0 +1,179 @@
1
+ import {
2
+ OauthTokenCandidate,
3
+ OauthTokenSelectUseCase,
4
+ OauthTokenWindowSnapshot,
5
+ } from './OauthTokenSelectUseCase';
6
+
7
+ const NOW = 1_000_000;
8
+ const HOUR = 3600;
9
+ const DAY = 86400;
10
+
11
+ const snapshot = (
12
+ overrides: Partial<OauthTokenWindowSnapshot>,
13
+ ): OauthTokenWindowSnapshot => ({
14
+ fiveHourUtilization: 0,
15
+ fiveHourReset: NOW + HOUR,
16
+ sevenDayUtilization: 0,
17
+ sevenDayReset: NOW + DAY,
18
+ ...overrides,
19
+ });
20
+
21
+ const candidate = (
22
+ name: string,
23
+ snapshotValue: OauthTokenWindowSnapshot | null,
24
+ ): OauthTokenCandidate => ({
25
+ name,
26
+ token: `fake-token-${name}`,
27
+ snapshot: snapshotValue,
28
+ });
29
+
30
+ describe('OauthTokenSelectUseCase', () => {
31
+ const useCase = new OauthTokenSelectUseCase();
32
+
33
+ it('selects the eligible token whose 7d window resets soonest', () => {
34
+ const result = useCase.run(
35
+ [
36
+ candidate(
37
+ 'far',
38
+ snapshot({ sevenDayUtilization: 0.1, sevenDayReset: NOW + 6 * DAY }),
39
+ ),
40
+ candidate(
41
+ 'soon',
42
+ snapshot({ sevenDayUtilization: 0.1, sevenDayReset: NOW + 2 * DAY }),
43
+ ),
44
+ candidate(
45
+ 'middle',
46
+ snapshot({ sevenDayUtilization: 0.1, sevenDayReset: NOW + 4 * DAY }),
47
+ ),
48
+ ],
49
+ NOW,
50
+ );
51
+
52
+ expect(result.selected?.name).toBe('soon');
53
+ expect(result.selected?.token).toBe('fake-token-soon');
54
+ });
55
+
56
+ it('excludes a token whose 5h window is less than 60% free', () => {
57
+ const result = useCase.run(
58
+ [
59
+ candidate('busy5h', snapshot({ fiveHourUtilization: 0.41 })),
60
+ candidate('ok', snapshot({ fiveHourUtilization: 0.4 })),
61
+ ],
62
+ NOW,
63
+ );
64
+
65
+ expect(result.selected?.name).toBe('ok');
66
+ const busy = result.metrics.find((m) => m.name === 'busy5h');
67
+ expect(busy?.eligible).toBe(false);
68
+ expect(busy?.exclusionReason).toContain('5h window');
69
+ });
70
+
71
+ it('treats exactly 40% used 5h utilization as eligible (boundary)', () => {
72
+ const result = useCase.run(
73
+ [candidate('boundary', snapshot({ fiveHourUtilization: 0.4 }))],
74
+ NOW,
75
+ );
76
+
77
+ expect(result.selected?.name).toBe('boundary');
78
+ });
79
+
80
+ it('excludes a token whose 7d window is less than 30% free', () => {
81
+ const result = useCase.run(
82
+ [
83
+ candidate('busy7d', snapshot({ sevenDayUtilization: 0.71 })),
84
+ candidate('ok', snapshot({ sevenDayUtilization: 0.7 })),
85
+ ],
86
+ NOW,
87
+ );
88
+
89
+ expect(result.selected?.name).toBe('ok');
90
+ const busy = result.metrics.find((m) => m.name === 'busy7d');
91
+ expect(busy?.eligible).toBe(false);
92
+ expect(busy?.exclusionReason).toContain('7d window');
93
+ });
94
+
95
+ it('treats a token with no snapshot as fully free', () => {
96
+ const result = useCase.run([candidate('fresh', null)], NOW);
97
+
98
+ expect(result.selected?.name).toBe('fresh');
99
+ const fresh = result.metrics.find((m) => m.name === 'fresh');
100
+ expect(fresh?.fiveHourFreeRatio).toBe(1);
101
+ expect(fresh?.sevenDayFreeRatio).toBe(1);
102
+ });
103
+
104
+ it('treats an expired window as fully free', () => {
105
+ const result = useCase.run(
106
+ [
107
+ candidate(
108
+ 'expired',
109
+ snapshot({
110
+ fiveHourUtilization: 0.99,
111
+ fiveHourReset: NOW - HOUR,
112
+ sevenDayUtilization: 0.99,
113
+ sevenDayReset: NOW - DAY,
114
+ }),
115
+ ),
116
+ ],
117
+ NOW,
118
+ );
119
+
120
+ expect(result.selected?.name).toBe('expired');
121
+ const expired = result.metrics.find((m) => m.name === 'expired');
122
+ expect(expired?.fiveHourFreeRatio).toBe(1);
123
+ expect(expired?.sevenDayFreeRatio).toBe(1);
124
+ });
125
+
126
+ it('treats a token with no active 7d window as having the farthest 7d end', () => {
127
+ const result = useCase.run(
128
+ [
129
+ candidate(
130
+ 'noSevenDay',
131
+ snapshot({ sevenDayReset: 0, sevenDayUtilization: 0 }),
132
+ ),
133
+ candidate(
134
+ 'activeSoon',
135
+ snapshot({
136
+ sevenDayUtilization: 0.1,
137
+ sevenDayReset: NOW + 3 * DAY,
138
+ }),
139
+ ),
140
+ ],
141
+ NOW,
142
+ );
143
+
144
+ expect(result.selected?.name).toBe('activeSoon');
145
+ const noSevenDay = result.metrics.find((m) => m.name === 'noSevenDay');
146
+ expect(noSevenDay?.sevenDayEndEpoch).toBe(NOW + 7 * DAY);
147
+ });
148
+
149
+ it('returns null selection when no token passes the filter', () => {
150
+ const result = useCase.run(
151
+ [
152
+ candidate('busy', snapshot({ fiveHourUtilization: 0.9 })),
153
+ candidate('alsoBusy', snapshot({ sevenDayUtilization: 0.9 })),
154
+ ],
155
+ NOW,
156
+ );
157
+
158
+ expect(result.selected).toBeNull();
159
+ });
160
+
161
+ it('returns null selection for an empty candidate list', () => {
162
+ const result = useCase.run([], NOW);
163
+
164
+ expect(result.selected).toBeNull();
165
+ expect(result.metrics).toEqual([]);
166
+ });
167
+
168
+ it('reports per-candidate metrics for every input token', () => {
169
+ const result = useCase.run(
170
+ [
171
+ candidate('a', snapshot({ fiveHourUtilization: 0.2 })),
172
+ candidate('b', snapshot({ fiveHourUtilization: 0.95 })),
173
+ ],
174
+ NOW,
175
+ );
176
+
177
+ expect(result.metrics.map((m) => m.name)).toEqual(['a', 'b']);
178
+ });
179
+ });