github-issue-tower-defence-management 1.54.0 → 1.56.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/CHANGELOG.md +14 -0
- package/README.md +23 -0
- package/bin/adapter/entry-points/cli/index.js +3 -1
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/adapter/entry-points/cli/projectConfig.js +5 -0
- package/bin/adapter/entry-points/cli/projectConfig.js.map +1 -1
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +3 -1
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
- package/bin/adapter/proxy/RateLimitCache.js +123 -0
- package/bin/adapter/proxy/RateLimitCache.js.map +1 -0
- package/bin/adapter/proxy/TokenListLoader.js +72 -0
- package/bin/adapter/proxy/TokenListLoader.js.map +1 -0
- package/bin/adapter/proxy/ensureProxyRunning.js +73 -0
- package/bin/adapter/proxy/ensureProxyRunning.js.map +1 -0
- package/bin/adapter/proxy/proxyEntry.js +96 -0
- package/bin/adapter/proxy/proxyEntry.js.map +1 -0
- package/bin/adapter/repositories/NodeLocalCommandRunner.js +10 -4
- package/bin/adapter/repositories/NodeLocalCommandRunner.js.map +1 -1
- package/bin/adapter/repositories/ProxyClaudeTokenUsageRepository.js +35 -0
- package/bin/adapter/repositories/ProxyClaudeTokenUsageRepository.js.map +1 -0
- package/bin/domain/entities/ClaudeTokenUsage.js +3 -0
- package/bin/domain/entities/ClaudeTokenUsage.js.map +1 -0
- package/bin/domain/usecases/ConvertCheckboxToIssueInStoryIssueUseCase.js +1 -1
- package/bin/domain/usecases/ConvertCheckboxToIssueInStoryIssueUseCase.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/StartPreparationUseCase.js +26 -2
- package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -1
- package/bin/domain/usecases/adapter-interfaces/ClaudeTokenUsageRepository.js +3 -0
- package/bin/domain/usecases/adapter-interfaces/ClaudeTokenUsageRepository.js.map +1 -0
- package/package.json +1 -1
- package/src/adapter/entry-points/cli/index.ts +5 -0
- package/src/adapter/entry-points/cli/projectConfig.ts +13 -0
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +5 -0
- package/src/adapter/proxy/RateLimitCache.test.ts +131 -0
- package/src/adapter/proxy/RateLimitCache.ts +112 -0
- package/src/adapter/proxy/TokenListLoader.test.ts +82 -0
- package/src/adapter/proxy/TokenListLoader.ts +35 -0
- package/src/adapter/proxy/ensureProxyRunning.test.ts +85 -0
- package/src/adapter/proxy/ensureProxyRunning.ts +41 -0
- package/src/adapter/proxy/proxyEntry.test.ts +48 -0
- package/src/adapter/proxy/proxyEntry.ts +69 -0
- package/src/adapter/repositories/NodeLocalCommandRunner.test.ts +3 -1
- package/src/adapter/repositories/NodeLocalCommandRunner.ts +18 -4
- package/src/adapter/repositories/ProxyClaudeTokenUsageRepository.test.ts +127 -0
- package/src/adapter/repositories/ProxyClaudeTokenUsageRepository.ts +36 -0
- package/src/domain/entities/ClaudeTokenUsage.ts +5 -0
- package/src/domain/usecases/ConvertCheckboxToIssueInStoryIssueUseCase.test.ts +26 -15
- package/src/domain/usecases/ConvertCheckboxToIssueInStoryIssueUseCase.ts +3 -1
- package/src/domain/usecases/HandleScheduledEventUseCase.ts +1 -0
- package/src/domain/usecases/StartPreparationUseCase.test.ts +308 -0
- package/src/domain/usecases/StartPreparationUseCase.ts +37 -1
- package/src/domain/usecases/adapter-interfaces/ClaudeTokenUsageRepository.ts +7 -0
- package/src/domain/usecases/adapter-interfaces/LocalCommandRunner.ts +5 -0
- package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
- 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/proxy/RateLimitCache.d.ts +14 -0
- package/types/adapter/proxy/RateLimitCache.d.ts.map +1 -0
- package/types/adapter/proxy/TokenListLoader.d.ts +2 -0
- package/types/adapter/proxy/TokenListLoader.d.ts.map +1 -0
- package/types/adapter/proxy/ensureProxyRunning.d.ts +2 -0
- package/types/adapter/proxy/ensureProxyRunning.d.ts.map +1 -0
- package/types/adapter/proxy/proxyEntry.d.ts +4 -0
- package/types/adapter/proxy/proxyEntry.d.ts.map +1 -0
- package/types/adapter/repositories/NodeLocalCommandRunner.d.ts +2 -2
- package/types/adapter/repositories/NodeLocalCommandRunner.d.ts.map +1 -1
- package/types/adapter/repositories/ProxyClaudeTokenUsageRepository.d.ts +11 -0
- package/types/adapter/repositories/ProxyClaudeTokenUsageRepository.d.ts.map +1 -0
- package/types/domain/entities/ClaudeTokenUsage.d.ts +6 -0
- package/types/domain/entities/ClaudeTokenUsage.d.ts.map +1 -0
- package/types/domain/usecases/ConvertCheckboxToIssueInStoryIssueUseCase.d.ts +2 -0
- package/types/domain/usecases/ConvertCheckboxToIssueInStoryIssueUseCase.d.ts.map +1 -1
- package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
- package/types/domain/usecases/StartPreparationUseCase.d.ts +4 -1
- package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -1
- package/types/domain/usecases/adapter-interfaces/ClaudeTokenUsageRepository.d.ts +7 -0
- package/types/domain/usecases/adapter-interfaces/ClaudeTokenUsageRepository.d.ts.map +1 -0
- package/types/domain/usecases/adapter-interfaces/LocalCommandRunner.d.ts +4 -1
- package/types/domain/usecases/adapter-interfaces/LocalCommandRunner.d.ts.map +1 -1
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { loadTokens } from './TokenListLoader';
|
|
5
|
+
|
|
6
|
+
describe('TokenListLoader', () => {
|
|
7
|
+
let tempDir: string;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'token-list-loader-'));
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('should return tokens in the order they appear in the JSON file', () => {
|
|
18
|
+
const filePath = path.join(tempDir, 'tokens.json');
|
|
19
|
+
fs.writeFileSync(
|
|
20
|
+
filePath,
|
|
21
|
+
JSON.stringify([
|
|
22
|
+
{ name: 'first', token: 'token-1' },
|
|
23
|
+
{ name: 'second', token: 'token-2' },
|
|
24
|
+
]),
|
|
25
|
+
);
|
|
26
|
+
expect(loadTokens(filePath)).toEqual(['token-1', 'token-2']);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should return null when the file does not exist', () => {
|
|
30
|
+
expect(loadTokens(path.join(tempDir, 'missing.json'))).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should return null when the JSON root is not an array', () => {
|
|
34
|
+
const filePath = path.join(tempDir, 'object.json');
|
|
35
|
+
fs.writeFileSync(filePath, JSON.stringify({ token: 'token-1' }));
|
|
36
|
+
expect(loadTokens(filePath)).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should return null when JSON parsing fails', () => {
|
|
40
|
+
const filePath = path.join(tempDir, 'malformed.json');
|
|
41
|
+
fs.writeFileSync(filePath, '{not json');
|
|
42
|
+
expect(loadTokens(filePath)).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should skip entries that do not contain a string token', () => {
|
|
46
|
+
const filePath = path.join(tempDir, 'mixed.json');
|
|
47
|
+
fs.writeFileSync(
|
|
48
|
+
filePath,
|
|
49
|
+
JSON.stringify([
|
|
50
|
+
{ name: 'no-token' },
|
|
51
|
+
{ name: 'first', token: 'token-1' },
|
|
52
|
+
{ name: 'numeric', token: 123 },
|
|
53
|
+
{ name: 'second', token: 'token-2' },
|
|
54
|
+
]),
|
|
55
|
+
);
|
|
56
|
+
expect(loadTokens(filePath)).toEqual(['token-1', 'token-2']);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should return null when every entry is invalid', () => {
|
|
60
|
+
const filePath = path.join(tempDir, 'no-valid-entries.json');
|
|
61
|
+
fs.writeFileSync(
|
|
62
|
+
filePath,
|
|
63
|
+
JSON.stringify([{ name: 'no-token' }, { token: 42 }]),
|
|
64
|
+
);
|
|
65
|
+
expect(loadTokens(filePath)).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should expand a leading tilde to the home directory', () => {
|
|
69
|
+
const home = os.homedir();
|
|
70
|
+
const fileName = `tdpm-loader-${process.pid}-${Date.now()}.json`;
|
|
71
|
+
const targetPath = path.join(home, fileName);
|
|
72
|
+
fs.writeFileSync(
|
|
73
|
+
targetPath,
|
|
74
|
+
JSON.stringify([{ name: 'first', token: 'home-token' }]),
|
|
75
|
+
);
|
|
76
|
+
try {
|
|
77
|
+
expect(loadTokens(`~/${fileName}`)).toEqual(['home-token']);
|
|
78
|
+
} finally {
|
|
79
|
+
fs.unlinkSync(targetPath);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
|
|
5
|
+
const expandHome = (filePath: string): string => {
|
|
6
|
+
if (filePath.startsWith('~/')) {
|
|
7
|
+
return path.join(os.homedir(), filePath.slice(2));
|
|
8
|
+
}
|
|
9
|
+
if (filePath === '~') {
|
|
10
|
+
return os.homedir();
|
|
11
|
+
}
|
|
12
|
+
return filePath;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
16
|
+
value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
17
|
+
|
|
18
|
+
export const loadTokens = (jsonPath: string): string[] | null => {
|
|
19
|
+
const resolved = expandHome(jsonPath);
|
|
20
|
+
if (!fs.existsSync(resolved)) return null;
|
|
21
|
+
try {
|
|
22
|
+
const raw = fs.readFileSync(resolved, 'utf8');
|
|
23
|
+
const parsed: unknown = JSON.parse(raw);
|
|
24
|
+
if (!Array.isArray(parsed)) return null;
|
|
25
|
+
const tokens: string[] = [];
|
|
26
|
+
for (const entry of parsed) {
|
|
27
|
+
if (isRecord(entry) && typeof entry.token === 'string') {
|
|
28
|
+
tokens.push(entry.token);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return tokens.length > 0 ? tokens : null;
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import * as net from 'net';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
|
|
4
|
+
const spawnCalls: unknown[][] = [];
|
|
5
|
+
const unrefMock = jest.fn();
|
|
6
|
+
const spawnReturnValue = { unref: unrefMock };
|
|
7
|
+
|
|
8
|
+
jest.mock('child_process', () => ({
|
|
9
|
+
spawn: (...args: unknown[]): { unref: jest.Mock } => {
|
|
10
|
+
spawnCalls.push(args);
|
|
11
|
+
return spawnReturnValue;
|
|
12
|
+
},
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
import { ensureProxyRunning } from './ensureProxyRunning';
|
|
16
|
+
|
|
17
|
+
describe('ensureProxyRunning', () => {
|
|
18
|
+
let server: net.Server | null = null;
|
|
19
|
+
let port = 0;
|
|
20
|
+
|
|
21
|
+
const startProbeServer = (): Promise<void> =>
|
|
22
|
+
new Promise((resolve) => {
|
|
23
|
+
server = net.createServer((socket) => socket.end());
|
|
24
|
+
server.listen(0, '127.0.0.1', () => {
|
|
25
|
+
const address = server?.address();
|
|
26
|
+
if (address !== null && typeof address === 'object') {
|
|
27
|
+
port = address.port;
|
|
28
|
+
}
|
|
29
|
+
resolve();
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
spawnCalls.length = 0;
|
|
35
|
+
unrefMock.mockReset();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterEach(async () => {
|
|
39
|
+
if (server !== null) {
|
|
40
|
+
await new Promise<void>((resolve) => server?.close(() => resolve()));
|
|
41
|
+
server = null;
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should short-circuit and not spawn when the port already responds', async () => {
|
|
46
|
+
await startProbeServer();
|
|
47
|
+
await ensureProxyRunning(port);
|
|
48
|
+
expect(spawnCalls).toHaveLength(0);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should spawn the proxy detached when the port is unresponsive', async () => {
|
|
52
|
+
const unusedPort = await new Promise<number>((resolve) => {
|
|
53
|
+
const probe = net.createServer();
|
|
54
|
+
probe.listen(0, '127.0.0.1', () => {
|
|
55
|
+
const address = probe.address();
|
|
56
|
+
const portValue =
|
|
57
|
+
address !== null && typeof address === 'object' ? address.port : 0;
|
|
58
|
+
probe.close(() => resolve(portValue));
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
await ensureProxyRunning(unusedPort);
|
|
63
|
+
|
|
64
|
+
expect(spawnCalls).toHaveLength(1);
|
|
65
|
+
const [program, args, options] = spawnCalls[0];
|
|
66
|
+
expect(program).toBe(process.execPath);
|
|
67
|
+
if (!Array.isArray(args)) {
|
|
68
|
+
throw new Error('Expected spawn args to be an array');
|
|
69
|
+
}
|
|
70
|
+
expect(args).toHaveLength(1);
|
|
71
|
+
const firstArg: unknown = args[0];
|
|
72
|
+
if (typeof firstArg !== 'string') {
|
|
73
|
+
throw new Error('Expected first spawn argument to be a string');
|
|
74
|
+
}
|
|
75
|
+
expect(firstArg.endsWith(path.join('proxy', 'proxyEntry.js'))).toBe(true);
|
|
76
|
+
const isObject = (value: unknown): value is { [key: string]: unknown } =>
|
|
77
|
+
value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
78
|
+
if (!isObject(options)) {
|
|
79
|
+
throw new Error('Expected spawn options to be an object');
|
|
80
|
+
}
|
|
81
|
+
expect(options.detached).toBe(true);
|
|
82
|
+
expect(options.stdio).toBe('ignore');
|
|
83
|
+
expect(unrefMock).toHaveBeenCalledTimes(1);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import * as net from 'net';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { PROXY_PORT } from './RateLimitCache';
|
|
5
|
+
|
|
6
|
+
const PROBE_TIMEOUT_MS = 200;
|
|
7
|
+
const STARTUP_WAIT_MS = 1500;
|
|
8
|
+
|
|
9
|
+
const isProxyResponding = (port: number): Promise<boolean> =>
|
|
10
|
+
new Promise((resolve) => {
|
|
11
|
+
const socket = new net.Socket();
|
|
12
|
+
let resolved = false;
|
|
13
|
+
const cleanup = (result: boolean): void => {
|
|
14
|
+
if (resolved) return;
|
|
15
|
+
resolved = true;
|
|
16
|
+
socket.destroy();
|
|
17
|
+
resolve(result);
|
|
18
|
+
};
|
|
19
|
+
socket.setTimeout(PROBE_TIMEOUT_MS);
|
|
20
|
+
socket.once('connect', () => cleanup(true));
|
|
21
|
+
socket.once('timeout', () => cleanup(false));
|
|
22
|
+
socket.once('error', () => cleanup(false));
|
|
23
|
+
socket.connect(port, '127.0.0.1');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const sleep = (ms: number): Promise<void> =>
|
|
27
|
+
new Promise((resolve) => setTimeout(resolve, ms));
|
|
28
|
+
|
|
29
|
+
export const ensureProxyRunning = async (
|
|
30
|
+
port: number = PROXY_PORT,
|
|
31
|
+
): Promise<void> => {
|
|
32
|
+
if (await isProxyResponding(port)) return;
|
|
33
|
+
const entryPath = path.resolve(__dirname, 'proxyEntry.js');
|
|
34
|
+
const child = spawn(process.execPath, [entryPath], {
|
|
35
|
+
detached: true,
|
|
36
|
+
stdio: 'ignore',
|
|
37
|
+
env: process.env,
|
|
38
|
+
});
|
|
39
|
+
child.unref();
|
|
40
|
+
await sleep(STARTUP_WAIT_MS);
|
|
41
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { extractToken } from './proxyEntry';
|
|
2
|
+
|
|
3
|
+
describe('extractToken', () => {
|
|
4
|
+
it('should return the token after a Bearer prefix', () => {
|
|
5
|
+
expect(extractToken('Bearer sk-ant-token-123')).toBe('sk-ant-token-123');
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
it('should accept a lowercase bearer prefix', () => {
|
|
9
|
+
expect(extractToken('bearer sk-ant-token-123')).toBe('sk-ant-token-123');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('should accept a mixed-case Bearer prefix', () => {
|
|
13
|
+
expect(extractToken('BeArEr sk-ant-token-123')).toBe('sk-ant-token-123');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('should trim surrounding whitespace from the token', () => {
|
|
17
|
+
expect(extractToken('Bearer sk-ant-token-123 ')).toBe(
|
|
18
|
+
'sk-ant-token-123',
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should pick the first value when authorization is an array', () => {
|
|
23
|
+
expect(extractToken(['Bearer token-a', 'Bearer token-b'])).toBe('token-a');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should return null when authorization is undefined', () => {
|
|
27
|
+
expect(extractToken(undefined)).toBeNull();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should return null when the prefix is missing', () => {
|
|
31
|
+
expect(extractToken('Basic some-credential')).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should return null when only the prefix is provided', () => {
|
|
35
|
+
expect(extractToken('Bearer ')).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should return null when the input is shorter than the prefix', () => {
|
|
39
|
+
expect(extractToken('Bear')).toBeNull();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should run in linear time on adversarial whitespace input', () => {
|
|
43
|
+
const adversarial = `bearer ${' '.repeat(50000)}`;
|
|
44
|
+
const startedAt = Date.now();
|
|
45
|
+
expect(extractToken(adversarial)).toBeNull();
|
|
46
|
+
expect(Date.now() - startedAt).toBeLessThan(500);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import * as http from 'http';
|
|
2
|
+
import * as https from 'https';
|
|
3
|
+
import { PROXY_PORT, writeRateLimit } from './RateLimitCache';
|
|
4
|
+
|
|
5
|
+
const UPSTREAM_HOST = 'api.anthropic.com';
|
|
6
|
+
|
|
7
|
+
const BEARER_PREFIX = 'bearer ';
|
|
8
|
+
|
|
9
|
+
const extractToken = (
|
|
10
|
+
authorization: string | string[] | undefined,
|
|
11
|
+
): string | null => {
|
|
12
|
+
const value = Array.isArray(authorization) ? authorization[0] : authorization;
|
|
13
|
+
if (typeof value !== 'string') return null;
|
|
14
|
+
if (value.length < BEARER_PREFIX.length) return null;
|
|
15
|
+
if (value.slice(0, BEARER_PREFIX.length).toLowerCase() !== BEARER_PREFIX)
|
|
16
|
+
return null;
|
|
17
|
+
const token = value.slice(BEARER_PREFIX.length).trim();
|
|
18
|
+
return token.length > 0 ? token : null;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const startProxy = (port: number): void => {
|
|
22
|
+
const server = http.createServer((clientRequest, clientResponse) => {
|
|
23
|
+
const token = extractToken(clientRequest.headers['authorization']);
|
|
24
|
+
const upstreamHeaders: Record<string, string | string[] | undefined> = {
|
|
25
|
+
...clientRequest.headers,
|
|
26
|
+
host: UPSTREAM_HOST,
|
|
27
|
+
};
|
|
28
|
+
const upstreamRequest = https.request(
|
|
29
|
+
{
|
|
30
|
+
host: UPSTREAM_HOST,
|
|
31
|
+
port: 443,
|
|
32
|
+
method: clientRequest.method,
|
|
33
|
+
path: clientRequest.url,
|
|
34
|
+
headers: upstreamHeaders,
|
|
35
|
+
},
|
|
36
|
+
(upstreamResponse) => {
|
|
37
|
+
if (token !== null) {
|
|
38
|
+
try {
|
|
39
|
+
writeRateLimit(token, upstreamResponse.headers);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
console.error('Failed to write rate limit cache:', error);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
clientResponse.writeHead(
|
|
45
|
+
upstreamResponse.statusCode ?? 502,
|
|
46
|
+
upstreamResponse.headers,
|
|
47
|
+
);
|
|
48
|
+
upstreamResponse.pipe(clientResponse);
|
|
49
|
+
},
|
|
50
|
+
);
|
|
51
|
+
upstreamRequest.on('error', (error) => {
|
|
52
|
+
console.error('Upstream request error:', error.message);
|
|
53
|
+
if (!clientResponse.headersSent) {
|
|
54
|
+
clientResponse.writeHead(502, { 'content-type': 'text/plain' });
|
|
55
|
+
}
|
|
56
|
+
clientResponse.end('Upstream error');
|
|
57
|
+
});
|
|
58
|
+
clientRequest.pipe(upstreamRequest);
|
|
59
|
+
});
|
|
60
|
+
server.listen(port, '127.0.0.1', () => {
|
|
61
|
+
console.log(`tdpm proxy listening on 127.0.0.1:${port}`);
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
if (require.main === module) {
|
|
66
|
+
startProxy(PROXY_PORT);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export { startProxy, extractToken };
|
|
@@ -32,7 +32,9 @@ describe('NodeLocalCommandRunner', () => {
|
|
|
32
32
|
stderr: '',
|
|
33
33
|
exitCode: 0,
|
|
34
34
|
});
|
|
35
|
-
expect(mockExecFileAsync).toHaveBeenCalledWith('echo', ['"test"']
|
|
35
|
+
expect(mockExecFileAsync).toHaveBeenCalledWith('echo', ['"test"'], {
|
|
36
|
+
encoding: 'utf8',
|
|
37
|
+
});
|
|
36
38
|
});
|
|
37
39
|
|
|
38
40
|
it('should handle command errors with exit code', async () => {
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
LocalCommandRunner,
|
|
3
|
+
LocalCommandRunnerOptions,
|
|
4
|
+
} from '../../domain/usecases/adapter-interfaces/LocalCommandRunner';
|
|
2
5
|
import { execFile } from 'child_process';
|
|
3
6
|
import { promisify } from 'util';
|
|
4
7
|
|
|
@@ -8,16 +11,27 @@ export class NodeLocalCommandRunner implements LocalCommandRunner {
|
|
|
8
11
|
async runCommand(
|
|
9
12
|
program: string,
|
|
10
13
|
args: string[],
|
|
14
|
+
options?: LocalCommandRunnerOptions,
|
|
11
15
|
): Promise<{
|
|
12
16
|
stdout: string;
|
|
13
17
|
stderr: string;
|
|
14
18
|
exitCode: number;
|
|
15
19
|
}> {
|
|
20
|
+
const execOptions: Parameters<typeof execFileAsync>[2] = {
|
|
21
|
+
encoding: 'utf8',
|
|
22
|
+
};
|
|
23
|
+
if (options?.env) {
|
|
24
|
+
execOptions.env = { ...process.env, ...options.env };
|
|
25
|
+
}
|
|
16
26
|
try {
|
|
17
|
-
const { stdout, stderr } = await execFileAsync(
|
|
27
|
+
const { stdout, stderr } = await execFileAsync(
|
|
28
|
+
program,
|
|
29
|
+
args,
|
|
30
|
+
execOptions,
|
|
31
|
+
);
|
|
18
32
|
return {
|
|
19
|
-
stdout,
|
|
20
|
-
stderr,
|
|
33
|
+
stdout: String(stdout),
|
|
34
|
+
stderr: String(stderr),
|
|
21
35
|
exitCode: 0,
|
|
22
36
|
};
|
|
23
37
|
} catch (error) {
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
const mockEnsureProxyRunning = jest.fn();
|
|
2
|
+
const mockReadRateLimit = jest.fn();
|
|
3
|
+
const mockLoadTokens = jest.fn();
|
|
4
|
+
|
|
5
|
+
jest.mock('../proxy/ensureProxyRunning', () => ({
|
|
6
|
+
ensureProxyRunning: mockEnsureProxyRunning,
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
jest.mock('../proxy/RateLimitCache', () => ({
|
|
10
|
+
PROXY_PORT: 8787,
|
|
11
|
+
readRateLimit: mockReadRateLimit,
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
jest.mock('../proxy/TokenListLoader', () => ({
|
|
15
|
+
loadTokens: mockLoadTokens,
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
import { ProxyClaudeTokenUsageRepository } from './ProxyClaudeTokenUsageRepository';
|
|
19
|
+
|
|
20
|
+
describe('ProxyClaudeTokenUsageRepository', () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
jest.clearAllMocks();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('ensureObservable', () => {
|
|
26
|
+
it('should start the proxy on the default port', async () => {
|
|
27
|
+
mockEnsureProxyRunning.mockResolvedValue(undefined);
|
|
28
|
+
const repository = new ProxyClaudeTokenUsageRepository('/tokens.json');
|
|
29
|
+
|
|
30
|
+
await repository.ensureObservable();
|
|
31
|
+
|
|
32
|
+
expect(mockEnsureProxyRunning.mock.calls).toEqual([[8787]]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should start the proxy on the configured port', async () => {
|
|
36
|
+
mockEnsureProxyRunning.mockResolvedValue(undefined);
|
|
37
|
+
const repository = new ProxyClaudeTokenUsageRepository(
|
|
38
|
+
'/tokens.json',
|
|
39
|
+
9999,
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
await repository.ensureObservable();
|
|
43
|
+
|
|
44
|
+
expect(mockEnsureProxyRunning.mock.calls).toEqual([[9999]]);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('getAvailableTokenUsages', () => {
|
|
49
|
+
it('should return an empty list when no token path is configured', async () => {
|
|
50
|
+
const repository = new ProxyClaudeTokenUsageRepository(null);
|
|
51
|
+
|
|
52
|
+
const result = await repository.getAvailableTokenUsages();
|
|
53
|
+
|
|
54
|
+
expect(result).toEqual([]);
|
|
55
|
+
expect(mockLoadTokens.mock.calls).toHaveLength(0);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should return an empty list when the token list cannot be loaded', async () => {
|
|
59
|
+
mockLoadTokens.mockReturnValue(null);
|
|
60
|
+
const repository = new ProxyClaudeTokenUsageRepository('/tokens.json');
|
|
61
|
+
|
|
62
|
+
const result = await repository.getAvailableTokenUsages();
|
|
63
|
+
|
|
64
|
+
expect(result).toEqual([]);
|
|
65
|
+
expect(mockLoadTokens.mock.calls).toEqual([['/tokens.json']]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should map each token to its cached utilization', async () => {
|
|
69
|
+
mockLoadTokens.mockReturnValue(['token-a', 'token-b']);
|
|
70
|
+
mockReadRateLimit.mockImplementation((token: string) => {
|
|
71
|
+
if (token === 'token-a') {
|
|
72
|
+
return {
|
|
73
|
+
fiveHourUtilization: 42,
|
|
74
|
+
fiveHourReset: 0,
|
|
75
|
+
sevenDayUtilization: 0,
|
|
76
|
+
sevenDayReset: 0,
|
|
77
|
+
blocked: false,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
});
|
|
82
|
+
const repository = new ProxyClaudeTokenUsageRepository('/tokens.json');
|
|
83
|
+
|
|
84
|
+
const result = await repository.getAvailableTokenUsages();
|
|
85
|
+
|
|
86
|
+
expect(result).toEqual([
|
|
87
|
+
{ token: 'token-a', fiveHourUtilization: 42, blocked: false },
|
|
88
|
+
{ token: 'token-b', fiveHourUtilization: 0, blocked: false },
|
|
89
|
+
]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should propagate the blocked status from the cache', async () => {
|
|
93
|
+
mockLoadTokens.mockReturnValue(['token-a']);
|
|
94
|
+
mockReadRateLimit.mockReturnValue({
|
|
95
|
+
fiveHourUtilization: 5,
|
|
96
|
+
fiveHourReset: 0,
|
|
97
|
+
sevenDayUtilization: 0,
|
|
98
|
+
sevenDayReset: 0,
|
|
99
|
+
blocked: true,
|
|
100
|
+
});
|
|
101
|
+
const repository = new ProxyClaudeTokenUsageRepository('/tokens.json');
|
|
102
|
+
|
|
103
|
+
const result = await repository.getAvailableTokenUsages();
|
|
104
|
+
|
|
105
|
+
expect(result).toEqual([
|
|
106
|
+
{ token: 'token-a', fiveHourUtilization: 5, blocked: true },
|
|
107
|
+
]);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('proxyBaseUrl', () => {
|
|
112
|
+
it('should build the loopback url for the default port', () => {
|
|
113
|
+
const repository = new ProxyClaudeTokenUsageRepository('/tokens.json');
|
|
114
|
+
|
|
115
|
+
expect(repository.proxyBaseUrl()).toBe('http://127.0.0.1:8787');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('should build the loopback url for the configured port', () => {
|
|
119
|
+
const repository = new ProxyClaudeTokenUsageRepository(
|
|
120
|
+
'/tokens.json',
|
|
121
|
+
9999,
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
expect(repository.proxyBaseUrl()).toBe('http://127.0.0.1:9999');
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { ClaudeTokenUsage } from '../../domain/entities/ClaudeTokenUsage';
|
|
2
|
+
import { ClaudeTokenUsageRepository } from '../../domain/usecases/adapter-interfaces/ClaudeTokenUsageRepository';
|
|
3
|
+
import { ensureProxyRunning } from '../proxy/ensureProxyRunning';
|
|
4
|
+
import { PROXY_PORT, readRateLimit } from '../proxy/RateLimitCache';
|
|
5
|
+
import { loadTokens } from '../proxy/TokenListLoader';
|
|
6
|
+
|
|
7
|
+
export class ProxyClaudeTokenUsageRepository implements ClaudeTokenUsageRepository {
|
|
8
|
+
constructor(
|
|
9
|
+
private readonly tokenListJsonPath: string | null,
|
|
10
|
+
private readonly port: number = PROXY_PORT,
|
|
11
|
+
) {}
|
|
12
|
+
|
|
13
|
+
ensureObservable = async (): Promise<void> => {
|
|
14
|
+
await ensureProxyRunning(this.port);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
getAvailableTokenUsages = async (): Promise<ClaudeTokenUsage[]> => {
|
|
18
|
+
if (this.tokenListJsonPath === null) {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
const tokens = loadTokens(this.tokenListJsonPath);
|
|
22
|
+
if (tokens === null) {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
return tokens.map((token) => {
|
|
26
|
+
const snapshot = readRateLimit(token);
|
|
27
|
+
return {
|
|
28
|
+
token,
|
|
29
|
+
fiveHourUtilization: snapshot ? snapshot.fiveHourUtilization : 0,
|
|
30
|
+
blocked: snapshot?.blocked ?? false,
|
|
31
|
+
};
|
|
32
|
+
});
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
proxyBaseUrl = (): string => `http://127.0.0.1:${this.port}`;
|
|
36
|
+
}
|