claude-org 0.2.2 → 0.4.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 +18 -0
- package/LICENSE +21 -21
- package/README.md +71 -9
- package/bin/claude-org.js +21 -3
- package/lib/config.js +42 -4
- package/lib/launch.js +38 -3
- package/lib/pick.js +9 -4
- package/lib/probe.js +90 -29
- package/lib/proxy.js +419 -105
- package/lib/status.js +3 -2
- package/lib/usage.js +7 -3
- package/package.json +1 -1
package/lib/proxy.js
CHANGED
|
@@ -16,21 +16,35 @@
|
|
|
16
16
|
// `Authorization: Bearer` alone, but if `x-api-key` is also present it is checked
|
|
17
17
|
// first and returns 401. The proxy drops x-api-key and sends Bearer only.
|
|
18
18
|
//
|
|
19
|
+
// The proxy holds org tokens, so it only serves the one claude it was started
|
|
20
|
+
// for: each request must carry a per-session secret (Authorization: Bearer,
|
|
21
|
+
// what claude sends for ANTHROPIC_AUTH_TOKEN), name the proxy itself in Host
|
|
22
|
+
// (no DNS rebinding from a browser), and be a GET/POST/DELETE under /v1/.
|
|
23
|
+
//
|
|
19
24
|
// Env (standalone mode):
|
|
20
|
-
// PORT
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
25
|
+
// PORT listen port (default 0 = OS picks; reported on the
|
|
26
|
+
// "listening on" stderr line)
|
|
27
|
+
// CLAUDE_ORG_PROXY_SECRET the Bearer value clients must send (required)
|
|
28
|
+
// CLAUDE_ORG_TOKEN_DIR where <name>.token files live (~/.claude/org-tokens)
|
|
29
|
+
// CLAUDE_ORG_ORDER org priority, comma-separated (default: alphabetical)
|
|
30
|
+
// CLAUDE_ORG_STRATEGY "least-utilized": prefer the usable org with the
|
|
31
|
+
// lowest 5h utilization seen so far (default: ORDER)
|
|
32
|
+
// CLAUDE_ORG_UPSTREAM forward target (default https://api.anthropic.com;
|
|
33
|
+
// anything else needs CLAUDE_ORG_UNSAFE_UPSTREAM=1)
|
|
34
|
+
// CLAUDE_ORG_REJECT_FILE test seam: orgs listed comma-separated in this file
|
|
35
|
+
// are treated as rate-limited
|
|
36
|
+
// CLAUDE_ORG_STATE_FILE where the org and model of the last /v1/messages
|
|
37
|
+
// response are written as JSON (unset: not written)
|
|
27
38
|
const http = require('http');
|
|
28
39
|
const https = require('https');
|
|
29
40
|
const fs = require('fs');
|
|
30
41
|
const path = require('path');
|
|
31
42
|
const os = require('os');
|
|
32
43
|
const zlib = require('zlib');
|
|
44
|
+
const crypto = require('crypto');
|
|
45
|
+
const { validOrgName, tokenFileProblem } = require('./config');
|
|
33
46
|
|
|
47
|
+
const DEFAULT_UPSTREAM = 'https://api.anthropic.com';
|
|
34
48
|
// How long an org stays out when it is org-wide limited but sent no reset time,
|
|
35
49
|
// and the longest a model-specific refusal keeps an (org, model) pair out.
|
|
36
50
|
const ORG_REJECT_FALLBACK_MS = 10 * 60 * 1000;
|
|
@@ -39,6 +53,37 @@ const MODEL_REJECT_MAX_MS = 10 * 60 * 1000;
|
|
|
39
53
|
// from exhausting memory: the raw body read, and the decompressed copy.
|
|
40
54
|
const ERROR_BODY_MAX_BYTES = 1024 * 1024;
|
|
41
55
|
const ERROR_DECODED_MAX_BYTES = 64 * 1024;
|
|
56
|
+
// The request body is buffered whole (it may be resent to another org). The
|
|
57
|
+
// API's own limit is 32 MB; this cap only keeps a runaway client from filling
|
|
58
|
+
// memory.
|
|
59
|
+
const REQUEST_BODY_MAX_BYTES = 64 * 1024 * 1024;
|
|
60
|
+
// Idle time on the upstream connection (no bytes in either direction) after
|
|
61
|
+
// which the request is given up and the next org tried. Claude Code's own
|
|
62
|
+
// API_TIMEOUT_MS defaults to the same 10 minutes.
|
|
63
|
+
const UPSTREAM_TIMEOUT_MS = 10 * 60 * 1000;
|
|
64
|
+
const ALLOWED_METHODS = new Set(['GET', 'POST', 'DELETE']);
|
|
65
|
+
// Hop-by-hop headers (RFC 9110 §7.6.1) describe one connection and must not be
|
|
66
|
+
// forwarded; the body is re-framed by this proxy anyway.
|
|
67
|
+
const HOP_BY_HOP = new Set(['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
|
|
68
|
+
'te', 'trailer', 'trailers', 'transfer-encoding', 'upgrade']);
|
|
69
|
+
|
|
70
|
+
// A copy of `headers` without the hop-by-hop ones and those the Connection
|
|
71
|
+
// header names.
|
|
72
|
+
function stripHopByHop(headers) {
|
|
73
|
+
const named = new Set(String(headers.connection || '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean));
|
|
74
|
+
const out = {};
|
|
75
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
76
|
+
const lk = k.toLowerCase();
|
|
77
|
+
if (!HOP_BY_HOP.has(lk) && !named.has(lk)) out[k] = v;
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Constant-time comparison that does not leak the secret's length either.
|
|
83
|
+
function secretMatches(given, secret) {
|
|
84
|
+
const h = s => crypto.createHash('sha256').update(String(s)).digest();
|
|
85
|
+
return crypto.timingSafeEqual(h(given), h(secret));
|
|
86
|
+
}
|
|
42
87
|
|
|
43
88
|
// The "model" field of a /v1/messages request body, or '?' when the body is
|
|
44
89
|
// not JSON (the proxy must still forward it). Used as is as the quarantine
|
|
@@ -52,11 +97,17 @@ function requestModel(body) {
|
|
|
52
97
|
}
|
|
53
98
|
}
|
|
54
99
|
|
|
55
|
-
//
|
|
56
|
-
// cut short.
|
|
100
|
+
// Client input for the log: limited to safe characters and cut short.
|
|
57
101
|
function logModel(model) {
|
|
58
102
|
return model.replace(/[^\w.:@/-]/g, '_').slice(0, 80);
|
|
59
103
|
}
|
|
104
|
+
// The query string is dropped: a client may put secrets there, and the log
|
|
105
|
+
// only needs to show which path was refused.
|
|
106
|
+
function logPath(url) {
|
|
107
|
+
const s = String(url);
|
|
108
|
+
const q = s.indexOf('?');
|
|
109
|
+
return (q === -1 ? s : s.slice(0, q) + '?...').replace(/[^\w.:@/?-]/g, '_').slice(0, 120);
|
|
110
|
+
}
|
|
60
111
|
|
|
61
112
|
// Milliseconds from a Retry-After header, which is either delay-seconds or an
|
|
62
113
|
// HTTP-date. NaN when absent or unparsable.
|
|
@@ -83,6 +134,8 @@ function readBody(res, maxBytes) {
|
|
|
83
134
|
});
|
|
84
135
|
res.on('end', () => resolve(Buffer.concat(chunks)));
|
|
85
136
|
res.on('error', reject);
|
|
137
|
+
// destroy() without an error (the client went away) emits neither
|
|
138
|
+
res.on('close', () => reject(new Error('upstream closed before the 429 body ended')));
|
|
86
139
|
});
|
|
87
140
|
}
|
|
88
141
|
|
|
@@ -117,23 +170,54 @@ function createProxy(opts = {}) {
|
|
|
117
170
|
|| path.join(os.homedir(), '.claude', 'org-tokens');
|
|
118
171
|
const ORDER = opts.order
|
|
119
172
|
|| (process.env.CLAUDE_ORG_ORDER || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
120
|
-
const UPSTREAM = new URL(opts.upstream || process.env.CLAUDE_ORG_UPSTREAM ||
|
|
173
|
+
const UPSTREAM = new URL(opts.upstream || process.env.CLAUDE_ORG_UPSTREAM || DEFAULT_UPSTREAM);
|
|
121
174
|
const REJECT_FILE = 'rejectFile' in opts ? opts.rejectFile : (process.env.CLAUDE_ORG_REJECT_FILE || '');
|
|
175
|
+
const STATE_FILE = 'stateFile' in opts ? opts.stateFile : (process.env.CLAUDE_ORG_STATE_FILE || '');
|
|
176
|
+
const STRATEGY = 'strategy' in opts ? opts.strategy : (process.env.CLAUDE_ORG_STRATEGY || '');
|
|
177
|
+
const SECRET = 'secret' in opts ? opts.secret : (process.env.CLAUDE_ORG_PROXY_SECRET || '');
|
|
178
|
+
const ALLOW_ANY_UPSTREAM = 'allowAnyUpstream' in opts ? opts.allowAnyUpstream : process.env.CLAUDE_ORG_UNSAFE_UPSTREAM === '1';
|
|
179
|
+
const MAX_BODY = opts.maxBodyBytes || REQUEST_BODY_MAX_BYTES;
|
|
180
|
+
const UPSTREAM_TIMEOUT = opts.upstreamTimeoutMs || UPSTREAM_TIMEOUT_MS;
|
|
122
181
|
const out = opts.log || process.stderr;
|
|
123
182
|
const transport = UPSTREAM.protocol === 'http:' ? http : https;
|
|
124
183
|
|
|
184
|
+
// Org tokens go to the upstream as Bearer, so the upstream is pinned to the
|
|
185
|
+
// real API unless a test says otherwise.
|
|
186
|
+
if (UPSTREAM.href !== `${DEFAULT_UPSTREAM}/` && !ALLOW_ANY_UPSTREAM) {
|
|
187
|
+
throw new Error(`CLAUDE_ORG_UPSTREAM=${UPSTREAM.href} is not ${DEFAULT_UPSTREAM}; org tokens are only sent there (set CLAUDE_ORG_UNSAFE_UPSTREAM=1 for a test upstream)`);
|
|
188
|
+
}
|
|
189
|
+
if (!SECRET) {
|
|
190
|
+
throw new Error('proxy secret missing: set CLAUDE_ORG_PROXY_SECRET (claude-org proxy generates one per session)');
|
|
191
|
+
}
|
|
192
|
+
|
|
125
193
|
function log(...a) {
|
|
126
194
|
out.write(`[claude-org-proxy ${new Date().toISOString().slice(11, 19)}] ${a.join(' ')}\n`);
|
|
127
195
|
}
|
|
196
|
+
const leastUtilized = STRATEGY === 'least-utilized';
|
|
197
|
+
if (STRATEGY && !leastUtilized) log(`unknown CLAUDE_ORG_STRATEGY "${STRATEGY}" -> using the static order`);
|
|
128
198
|
|
|
199
|
+
const reportedBadNames = new Set();
|
|
129
200
|
function listOrgs() {
|
|
130
|
-
const
|
|
201
|
+
const names = fs.readdirSync(TOKEN_DIR).filter(f => f.endsWith('.token')).map(f => f.slice(0, -6));
|
|
202
|
+
for (const n of names) {
|
|
203
|
+
if (validOrgName(n) || reportedBadNames.has(n)) continue;
|
|
204
|
+
reportedBadNames.add(n);
|
|
205
|
+
log(`ignoring ${TOKEN_DIR}/${logModel(n)}.token: not an org name (letters, digits, _ and - only)`);
|
|
206
|
+
}
|
|
207
|
+
const files = names.filter(validOrgName).sort();
|
|
131
208
|
if (ORDER.length === 0) return files;
|
|
132
209
|
const known = new Set(files);
|
|
133
210
|
return ORDER.filter(o => known.has(o)).concat(files.filter(f => !ORDER.includes(f)));
|
|
134
211
|
}
|
|
212
|
+
// Throws when the file is missing or not private to the user (S5): the
|
|
213
|
+
// caller skips that org for this request.
|
|
135
214
|
function readToken(name) {
|
|
136
|
-
|
|
215
|
+
const f = path.join(TOKEN_DIR, `${name}.token`);
|
|
216
|
+
const problem = tokenFileProblem(f);
|
|
217
|
+
if (problem) throw new Error(`token file ${f} ${problem}`);
|
|
218
|
+
const tok = fs.readFileSync(f, 'utf8').replace(/[\r\n]/g, '');
|
|
219
|
+
if (!tok) throw new Error(`token file ${f} is empty`);
|
|
220
|
+
return tok;
|
|
137
221
|
}
|
|
138
222
|
|
|
139
223
|
// Per-org "rate-limited" memory: name -> recovery epoch (ms). Expired entries
|
|
@@ -163,125 +247,313 @@ function createProxy(opts = {}) {
|
|
|
163
247
|
if (Date.now() >= until) { rejectedUntil.delete(name); return false; }
|
|
164
248
|
return true;
|
|
165
249
|
}
|
|
250
|
+
// Latest 5h utilization (0..1) each org reported, valid until that window's
|
|
251
|
+
// reset: name -> { util, until (epoch ms) }. An org that is not picked would
|
|
252
|
+
// otherwise keep its old high value after its window resets.
|
|
253
|
+
const utilization = new Map();
|
|
254
|
+
// Orgs that answered without a utilization header, so they are not put first
|
|
255
|
+
// again and again as "not measured yet".
|
|
256
|
+
const answeredUnmeasured = new Set();
|
|
257
|
+
function utilOf(name) {
|
|
258
|
+
const e = utilization.get(name);
|
|
259
|
+
if (e === undefined) return undefined;
|
|
260
|
+
if (Date.now() >= e.until) { utilization.delete(name); return undefined; }
|
|
261
|
+
return e.util;
|
|
262
|
+
}
|
|
263
|
+
function recordUtil(name, util, reset) {
|
|
264
|
+
const until = Number.isFinite(reset) ? reset * 1000 : Infinity;
|
|
265
|
+
utilization.set(name, { util, until });
|
|
266
|
+
answeredUnmeasured.delete(name);
|
|
267
|
+
}
|
|
166
268
|
// Usable orgs first, in priority order; rate-limited orgs are moved to the
|
|
167
269
|
// tail (kept as a last resort when everything is limited).
|
|
270
|
+
// least-utilized: among the usable orgs, those that have not been measured
|
|
271
|
+
// come first (in priority order) so that each is measured once, then the
|
|
272
|
+
// rest by utilization, lowest first, then those that answered without a
|
|
273
|
+
// utilization. Utilization is a share of each org's own plan, not an
|
|
274
|
+
// absolute amount, so a larger plan is picked more often.
|
|
168
275
|
function pickOrder(model) {
|
|
169
276
|
const orgs = listOrgs();
|
|
170
277
|
const blocked = o => isRejected(o) || isModelRejected(o, model);
|
|
171
|
-
|
|
278
|
+
let usable = orgs.filter(o => !blocked(o));
|
|
279
|
+
if (leastUtilized) {
|
|
280
|
+
const known = usable.filter(o => utilOf(o) !== undefined).sort((a, b) => utilOf(a) - utilOf(b));
|
|
281
|
+
const unknown = usable.filter(o => utilOf(o) === undefined);
|
|
282
|
+
usable = unknown.filter(o => !answeredUnmeasured.has(o))
|
|
283
|
+
.concat(known, unknown.filter(o => answeredUnmeasured.has(o)));
|
|
284
|
+
}
|
|
285
|
+
return usable.concat(orgs.filter(blocked));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Claude Code does not show the x-claude-org response header, so the org in
|
|
289
|
+
// use is written to a file that a statusline or hook can read. Written via a
|
|
290
|
+
// rename so a reader never sees a half-written file. No prompt or token goes in.
|
|
291
|
+
// The model is stored in full (JSON escapes it), unlike the cut log copy, so
|
|
292
|
+
// two long model names stay distinguishable.
|
|
293
|
+
// On Windows the rename fails while a reader holds the file open; that write
|
|
294
|
+
// is skipped and only the first failure is logged, to keep the log readable.
|
|
295
|
+
let stateWriteFailed = false;
|
|
296
|
+
function writeState(org, model, status) {
|
|
297
|
+
if (!STATE_FILE) return;
|
|
298
|
+
const tmp = `${STATE_FILE}.${process.pid}.tmp`;
|
|
299
|
+
try {
|
|
300
|
+
fs.writeFileSync(tmp, JSON.stringify({ org, model, status, at: new Date().toISOString() }) + '\n');
|
|
301
|
+
fs.renameSync(tmp, STATE_FILE);
|
|
302
|
+
} catch (e) {
|
|
303
|
+
try { fs.unlinkSync(tmp); } catch { /* the tmp file was never created */ }
|
|
304
|
+
if (!stateWriteFailed) log(`cannot write state file ${STATE_FILE}: ${e.message} (further failures are not logged)`);
|
|
305
|
+
stateWriteFailed = true;
|
|
306
|
+
}
|
|
172
307
|
}
|
|
173
308
|
|
|
174
|
-
|
|
309
|
+
// One upstream request. Resolves with the response once its headers are in;
|
|
310
|
+
// rejects on connection errors, on idle timeout, and when `signal` aborts
|
|
311
|
+
// (the client went away). The response stream can still fail later; the
|
|
312
|
+
// caller listens for that.
|
|
313
|
+
function forward(token, method, urlPath, headers, body, signal) {
|
|
175
314
|
return new Promise((resolve, reject) => {
|
|
176
|
-
const h =
|
|
315
|
+
const h = stripHopByHop(headers);
|
|
177
316
|
for (const k of Object.keys(h)) {
|
|
178
317
|
const lk = k.toLowerCase();
|
|
179
318
|
if (lk === 'x-api-key' || lk === 'host' || lk === 'content-length' || lk === 'authorization') delete h[k];
|
|
180
319
|
}
|
|
181
|
-
h.authorization = `Bearer ${
|
|
320
|
+
h.authorization = `Bearer ${token}`;
|
|
182
321
|
h.host = UPSTREAM.host;
|
|
183
322
|
if (body.length) h['content-length'] = String(body.length);
|
|
184
323
|
const req = transport.request(
|
|
185
|
-
{
|
|
324
|
+
{
|
|
325
|
+
hostname: UPSTREAM.hostname,
|
|
326
|
+
port: UPSTREAM.port || (transport === https ? 443 : 80),
|
|
327
|
+
path: urlPath,
|
|
328
|
+
method,
|
|
329
|
+
headers: h,
|
|
330
|
+
signal,
|
|
331
|
+
timeout: UPSTREAM_TIMEOUT,
|
|
332
|
+
},
|
|
186
333
|
res => resolve(res),
|
|
187
334
|
);
|
|
335
|
+
req.on('timeout', () => req.destroy(new Error(`no data from upstream for ${UPSTREAM_TIMEOUT} ms`)));
|
|
188
336
|
req.on('error', reject);
|
|
189
337
|
if (body.length) req.write(body);
|
|
190
338
|
req.end();
|
|
191
339
|
});
|
|
192
340
|
}
|
|
193
341
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
342
|
+
function refuse(clientRes, status, message) {
|
|
343
|
+
clientRes.writeHead(status, { 'content-type': 'application/json', connection: 'close' });
|
|
344
|
+
clientRes.end(JSON.stringify({ type: 'error', error: { type: 'claude_org_proxy', message } }));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Checks done before any body is read or anything is forwarded. Returns
|
|
348
|
+
// [status, message] for a request to refuse, or null.
|
|
349
|
+
function gate(clientReq) {
|
|
350
|
+
const auth = clientReq.headers.authorization || '';
|
|
351
|
+
if (!auth.startsWith('Bearer ') || !secretMatches(auth.slice(7), SECRET)) {
|
|
352
|
+
return [401, 'missing or wrong proxy secret (the Bearer token claude-org proxy gave this session)'];
|
|
353
|
+
}
|
|
354
|
+
const addr = server.address(); // null once the server is closing
|
|
355
|
+
if (!addr) return [503, 'proxy is shutting down'];
|
|
356
|
+
const expectedHost = `127.0.0.1:${addr.port}`;
|
|
357
|
+
if (clientReq.headers.host !== expectedHost) return [403, `Host must be ${expectedHost}`];
|
|
358
|
+
if (!ALLOWED_METHODS.has(clientReq.method)) return [405, `method ${clientReq.method} is not forwarded`];
|
|
359
|
+
// The path is forwarded as is, so nothing that an upstream could
|
|
360
|
+
// normalise out of /v1/ ("..", "//", "\\", %2e) is accepted.
|
|
361
|
+
const pathOnly = clientReq.url.split('?')[0];
|
|
362
|
+
if (!pathOnly.startsWith('/v1/') || /\.\.|\/\/|\\|%2e/i.test(pathOnly)) return [404, 'only /v1/* is forwarded'];
|
|
363
|
+
const announced = Number(clientReq.headers['content-length']);
|
|
364
|
+
if (announced > MAX_BODY) return [413, `request body larger than ${MAX_BODY} bytes`];
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function handle(clientReq, clientRes, body) {
|
|
369
|
+
// The client going away (Claude Code cancelled, or exited) cancels the
|
|
370
|
+
// upstream request in flight and stops a response being piped, so no org
|
|
371
|
+
// keeps generating for nobody.
|
|
372
|
+
const abort = new AbortController();
|
|
373
|
+
let clientGone = false;
|
|
374
|
+
let up = null;
|
|
375
|
+
clientRes.on('close', () => {
|
|
376
|
+
if (clientRes.writableFinished) return;
|
|
377
|
+
clientGone = true;
|
|
378
|
+
abort.abort();
|
|
379
|
+
if (up) up.destroy();
|
|
380
|
+
});
|
|
381
|
+
const isModelReq = clientReq.url.startsWith('/v1/messages');
|
|
382
|
+
const model = isModelReq ? requestModel(body) : '';
|
|
383
|
+
let order;
|
|
384
|
+
try {
|
|
385
|
+
order = pickOrder(model);
|
|
386
|
+
} catch (e) {
|
|
387
|
+
log(`cannot list orgs in ${TOKEN_DIR}: ${e.message}`);
|
|
388
|
+
order = [];
|
|
389
|
+
}
|
|
390
|
+
let attempt = 0;
|
|
391
|
+
let modelRefusals = 0;
|
|
392
|
+
for (const org of order) {
|
|
393
|
+
attempt++;
|
|
394
|
+
if (clientGone) return;
|
|
395
|
+
let token;
|
|
396
|
+
try {
|
|
397
|
+
token = readToken(org);
|
|
398
|
+
} catch (e) {
|
|
399
|
+
log(`skipping ${org}: ${e.message}`);
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
try {
|
|
403
|
+
up = await forward(token, clientReq.method, clientReq.url, clientReq.headers, body, abort.signal);
|
|
404
|
+
} catch (e) {
|
|
405
|
+
if (clientGone) return;
|
|
406
|
+
log(`upstream error via ${org}: ${e.message}`);
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
// A response that fails after its headers (idle timeout, reset) is only
|
|
410
|
+
// logged here; whether the client is cut off is decided where it is piped.
|
|
411
|
+
up.on('error', e => log(`upstream error via ${org} while reading the response: ${e.message}`));
|
|
412
|
+
const hdr = name => up.headers[`anthropic-ratelimit-unified-${name}`];
|
|
413
|
+
const status5h = hdr('5h-status');
|
|
414
|
+
const status7d = hdr('7d-status');
|
|
415
|
+
const claim = hdr('representative-claim');
|
|
416
|
+
const util5h = Number(hdr('5h-utilization'));
|
|
417
|
+
if (isModelReq && hdr('5h-utilization') !== undefined && Number.isFinite(util5h)) {
|
|
418
|
+
recordUtil(org, util5h, Number(hdr('5h-reset')));
|
|
419
|
+
} else if (isModelReq) {
|
|
420
|
+
answeredUnmeasured.add(org);
|
|
421
|
+
}
|
|
422
|
+
const isLast = attempt >= order.length;
|
|
423
|
+
// A 401 means the org's token is expired or revoked (they last about a
|
|
424
|
+
// year), not that the request is bad: the org is kept out for a while
|
|
425
|
+
// and the next one tried. The ALERT line is the fixed format a
|
|
426
|
+
// supervisor can watch for (README, "Unattended use").
|
|
427
|
+
if (isModelReq && up.statusCode === 401) {
|
|
428
|
+
log(`ALERT token-rejected org=${org} status=401 -> run: claude setup-token (org ${org}) and overwrite ${path.join(TOKEN_DIR, `${org}.token`)}`);
|
|
429
|
+
rejectedUntil.set(org, Date.now() + ORG_REJECT_FALLBACK_MS);
|
|
430
|
+
if (!isLast) {
|
|
431
|
+
up.resume();
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
// The 5h and 7d windows cover every model of the org, so only those make
|
|
436
|
+
// the org as a whole unusable. Other refusals are per model: a used-up
|
|
437
|
+
// per-model weekly allowance (claim=seven_day_overage_included) or a model
|
|
438
|
+
// that needs usage credits (429 with no window headers). The overall
|
|
439
|
+
// "unified-status" header is not used: it also reads "rejected" for those.
|
|
440
|
+
const orgWide = status5h === 'rejected' || status7d === 'rejected';
|
|
441
|
+
const limited = up.statusCode === 429 || orgWide;
|
|
442
|
+
const windows = `5h=${status5h ?? '?'}${claim ? `, claim=${claim}` : ''}`;
|
|
443
|
+
// A 429 body is a small JSON error; buffer it to log the reason and,
|
|
444
|
+
// if no org is left, to hand it to the client unchanged.
|
|
445
|
+
let errBody = null;
|
|
446
|
+
let reason = '';
|
|
447
|
+
if (isModelReq && up.statusCode === 429) {
|
|
207
448
|
try {
|
|
208
|
-
|
|
449
|
+
errBody = await readBody(up, ERROR_BODY_MAX_BYTES);
|
|
209
450
|
} catch (e) {
|
|
210
|
-
log(`upstream error via ${org}: ${e.message}`);
|
|
451
|
+
log(`upstream error via ${org} while reading 429 body: ${e.message}`);
|
|
211
452
|
continue;
|
|
212
453
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
// per-model
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
}
|
|
236
|
-
reason = describeError(errBody, up.headers['content-encoding']);
|
|
237
|
-
}
|
|
238
|
-
const modelRefused = isModelReq && up.statusCode === 429 && !orgWide;
|
|
239
|
-
if (modelRefused) {
|
|
240
|
-
// retry-after is honoured when shorter (a transient per-minute limit
|
|
241
|
-
// must not keep the preferred org out for long); a long one (a weekly
|
|
242
|
-
// per-model allowance) is capped so the org is re-checked now and then.
|
|
243
|
-
const ra = retryAfterMs(up.headers['retry-after']);
|
|
244
|
-
const ms = Number.isFinite(ra) ? Math.min(ra, MODEL_REJECT_MAX_MS) : MODEL_REJECT_MAX_MS;
|
|
245
|
-
modelRejectedUntil.set(`${org}|${model}`, Date.now() + ms);
|
|
246
|
-
}
|
|
247
|
-
const isLast = attempt >= order.length;
|
|
248
|
-
if (limited && isModelReq && !isLast) {
|
|
249
|
-
if (orgWide) {
|
|
250
|
-
// When both windows are rejected the org is back only after the later reset.
|
|
251
|
-
const resets = [['5h', status5h], ['7d', status7d]]
|
|
252
|
-
.filter(([, s]) => s === 'rejected')
|
|
253
|
-
.map(([w]) => Number(hdr(`${w}-reset`)))
|
|
254
|
-
.filter(Number.isFinite);
|
|
255
|
-
rejectedUntil.set(org, resets.length ? Math.max(...resets) * 1000 : Date.now() + ORG_REJECT_FALLBACK_MS);
|
|
256
|
-
log(`${org} is rate-limited (HTTP ${up.statusCode}, ${windows}) -> retrying with next org${reason ? ` (${reason})` : ''}`);
|
|
257
|
-
} else {
|
|
258
|
-
modelRefusals++;
|
|
259
|
-
log(`${org} rejected model ${logModel(model)} (HTTP ${up.statusCode}, ${windows}, ${reason}) -> retrying with next org; ${org} stays in use for other models`);
|
|
260
|
-
}
|
|
261
|
-
if (!errBody) up.resume(); // discard the response body and try the next org
|
|
262
|
-
continue;
|
|
454
|
+
reason = describeError(errBody, up.headers['content-encoding']);
|
|
455
|
+
}
|
|
456
|
+
const modelRefused = isModelReq && up.statusCode === 429 && !orgWide;
|
|
457
|
+
if (modelRefused) {
|
|
458
|
+
// retry-after is honoured when shorter (a transient per-minute limit
|
|
459
|
+
// must not keep the preferred org out for long); a long one (a weekly
|
|
460
|
+
// per-model allowance) is capped so the org is re-checked now and then.
|
|
461
|
+
const ra = retryAfterMs(up.headers['retry-after']);
|
|
462
|
+
const ms = Number.isFinite(ra) ? Math.min(ra, MODEL_REJECT_MAX_MS) : MODEL_REJECT_MAX_MS;
|
|
463
|
+
modelRejectedUntil.set(`${org}|${model}`, Date.now() + ms);
|
|
464
|
+
}
|
|
465
|
+
if (limited && isModelReq && !isLast) {
|
|
466
|
+
if (orgWide) {
|
|
467
|
+
// When both windows are rejected the org is back only after the later reset.
|
|
468
|
+
const resets = [['5h', status5h], ['7d', status7d]]
|
|
469
|
+
.filter(([, s]) => s === 'rejected')
|
|
470
|
+
.map(([w]) => Number(hdr(`${w}-reset`)))
|
|
471
|
+
.filter(Number.isFinite);
|
|
472
|
+
rejectedUntil.set(org, resets.length ? Math.max(...resets) * 1000 : Date.now() + ORG_REJECT_FALLBACK_MS);
|
|
473
|
+
log(`${org} is rate-limited (HTTP ${up.statusCode}, ${windows}) -> retrying with next org${reason ? ` (${reason})` : ''}`);
|
|
474
|
+
} else {
|
|
475
|
+
modelRefusals++;
|
|
476
|
+
log(`${org} rejected model ${logModel(model)} (HTTP ${up.statusCode}, ${windows}, ${reason}) -> retrying with next org; ${org} stays in use for other models`);
|
|
263
477
|
}
|
|
264
|
-
if (
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
478
|
+
if (!errBody) up.resume(); // discard the response body and try the next org
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
if (isModelReq) {
|
|
482
|
+
const util = hdr('5h-utilization');
|
|
483
|
+
log(`${clientReq.method} ${clientReq.url} via ${org} model=${logModel(model)} -> ${up.statusCode} (5h ${status5h ?? '?'} ${util ?? '?'})${reason ? ` ${reason}` : ''}`);
|
|
484
|
+
writeState(org, model, up.statusCode);
|
|
485
|
+
if (modelRefused && modelRefusals === attempt - 1) {
|
|
486
|
+
log(`all orgs rejected model ${logModel(model)} -> switch the model (e.g. /model) or wait for that model's limit to reset; the org tokens are not the cause`);
|
|
270
487
|
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
clientRes.end(errBody);
|
|
274
|
-
} else {
|
|
275
|
-
up.pipe(clientRes); // SSE streaming is piped through untouched
|
|
488
|
+
if (limited) {
|
|
489
|
+
log(`ALERT no-org-available model=${logModel(model)} reason=${modelRefused ? 'model-rejected' : 'rate-limited'} status=${up.statusCode}`);
|
|
276
490
|
}
|
|
491
|
+
}
|
|
492
|
+
if (clientGone) { up.destroy(); return; }
|
|
493
|
+
clientRes.writeHead(up.statusCode, { ...stripHopByHop(up.headers), 'x-claude-org': org });
|
|
494
|
+
if (errBody) {
|
|
495
|
+
clientRes.end(errBody);
|
|
496
|
+
} else {
|
|
497
|
+
up.once('error', () => clientRes.destroy()); // the client sees a reset, not a truncated OK
|
|
498
|
+
up.pipe(clientRes); // SSE streaming is piped through untouched
|
|
499
|
+
}
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
if (clientGone) return;
|
|
503
|
+
if (isModelReq) {
|
|
504
|
+
writeState(null, model, 503); // no org served it
|
|
505
|
+
log(`ALERT no-org-available model=${logModel(model)} reason=${order.length ? 'unreachable' : 'no-tokens'} status=503`);
|
|
506
|
+
}
|
|
507
|
+
refuse(clientRes, 503, 'all orgs rate-limited or unreachable');
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Nothing in here may take the process down: with no proxy, every claude
|
|
511
|
+
// behind it is stranded.
|
|
512
|
+
const server = http.createServer((clientReq, clientRes) => {
|
|
513
|
+
try {
|
|
514
|
+
accept(clientReq, clientRes);
|
|
515
|
+
} catch (e) {
|
|
516
|
+
log(`internal error on ${clientReq.method} ${logPath(clientReq.url)}: ${e.stack || e.message}`);
|
|
517
|
+
if (!clientRes.headersSent) refuse(clientRes, 500, 'proxy internal error (see the proxy log)');
|
|
518
|
+
else clientRes.destroy();
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
function accept(clientReq, clientRes) {
|
|
523
|
+
clientReq.on('error', e => log(`client request error: ${e.message}`));
|
|
524
|
+
clientRes.on('error', e => log(`client response error: ${e.message}`));
|
|
525
|
+
const denied = gate(clientReq);
|
|
526
|
+
if (denied) {
|
|
527
|
+
log(`refused ${clientReq.method} ${logPath(clientReq.url)}: ${denied[1]} (HTTP ${denied[0]})`);
|
|
528
|
+
refuse(clientRes, denied[0], denied[1]);
|
|
529
|
+
return; // the rest of the request body is discarded by node
|
|
530
|
+
}
|
|
531
|
+
const chunks = [];
|
|
532
|
+
let size = 0;
|
|
533
|
+
let tooLarge = false;
|
|
534
|
+
clientReq.on('data', c => {
|
|
535
|
+
if (tooLarge) return;
|
|
536
|
+
size += c.length;
|
|
537
|
+
if (size > MAX_BODY) {
|
|
538
|
+
tooLarge = true;
|
|
539
|
+
chunks.length = 0;
|
|
540
|
+
log(`refused ${clientReq.method} ${logPath(clientReq.url)}: request body larger than ${MAX_BODY} bytes (HTTP 413)`);
|
|
541
|
+
refuse(clientRes, 413, `request body larger than ${MAX_BODY} bytes`);
|
|
277
542
|
return;
|
|
278
543
|
}
|
|
279
|
-
|
|
280
|
-
clientRes.end(JSON.stringify({ type: 'error', error: { type: 'claude_org_proxy', message: 'all orgs rate-limited or unreachable' } }));
|
|
544
|
+
chunks.push(c);
|
|
281
545
|
});
|
|
282
|
-
|
|
546
|
+
clientReq.on('end', () => {
|
|
547
|
+
if (tooLarge) return;
|
|
548
|
+
handle(clientReq, clientRes, Buffer.concat(chunks)).catch(e => {
|
|
549
|
+
log(`internal error on ${clientReq.method} ${logPath(clientReq.url)}: ${e.stack || e.message}`);
|
|
550
|
+
if (!clientRes.headersSent) refuse(clientRes, 500, 'proxy internal error (see the proxy log)');
|
|
551
|
+
else clientRes.destroy();
|
|
552
|
+
});
|
|
553
|
+
});
|
|
554
|
+
}
|
|
283
555
|
|
|
284
|
-
return { server, listOrgs, log, upstream: UPSTREAM };
|
|
556
|
+
return { server, listOrgs, log, upstream: UPSTREAM, strategy: leastUtilized ? 'least-utilized' : 'order' };
|
|
285
557
|
}
|
|
286
558
|
|
|
287
559
|
// `claude-org proxy [claude args...]`: start an in-process proxy on a random
|
|
@@ -291,7 +563,14 @@ async function proxyLaunch(args) {
|
|
|
291
563
|
const { listOrgs, tokenDir, resolvePrefer } = require('./config');
|
|
292
564
|
const { runClaude } = require('./launch');
|
|
293
565
|
|
|
294
|
-
|
|
566
|
+
// An org whose token file the proxy would refuse (S5) is reported here, at
|
|
567
|
+
// launch, not only in the proxy log after every request has failed.
|
|
568
|
+
const orgs = listOrgs().filter(name => {
|
|
569
|
+
const f = path.join(tokenDir(), `${name}.token`);
|
|
570
|
+
const problem = tokenFileProblem(f);
|
|
571
|
+
if (problem) console.error(`claude-org: skipping org ${name}: token file ${f} ${problem}`);
|
|
572
|
+
return !problem;
|
|
573
|
+
});
|
|
295
574
|
if (orgs.length < 2) {
|
|
296
575
|
console.error(`claude-org: proxy には org トークンが 2 つ以上必要です(${tokenDir()} に ${orgs.join(' ')})`);
|
|
297
576
|
console.error(` 取得: claude setup-token でもう片方の org を選び、${tokenDir()}/<name>.token に保存`);
|
|
@@ -299,15 +578,36 @@ async function proxyLaunch(args) {
|
|
|
299
578
|
}
|
|
300
579
|
|
|
301
580
|
const order = process.env.CLAUDE_ORG_ORDER || resolvePrefer();
|
|
302
|
-
const
|
|
581
|
+
const runDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-org-proxy-'));
|
|
582
|
+
const logf = path.join(runDir, 'proxy.log');
|
|
583
|
+
const stateFile = path.join(runDir, 'state.json');
|
|
303
584
|
const logStream = fs.createWriteStream(logf);
|
|
304
|
-
|
|
585
|
+
// A log that cannot be written (disk full, dir removed) must not take the
|
|
586
|
+
// proxy down with an unhandled 'error'.
|
|
587
|
+
let logFailed = false;
|
|
588
|
+
logStream.on('error', e => {
|
|
589
|
+
if (!logFailed) console.error(`claude-org: cannot write the proxy log ${logf}: ${e.message}`);
|
|
590
|
+
logFailed = true;
|
|
591
|
+
});
|
|
592
|
+
// Only this claude may use this proxy (and so the org tokens): it must send
|
|
593
|
+
// this per-session secret as its Bearer token. It goes into the child's env
|
|
594
|
+
// and nowhere else.
|
|
595
|
+
const secret = crypto.randomBytes(32).toString('hex');
|
|
596
|
+
let proxy;
|
|
597
|
+
try {
|
|
598
|
+
proxy = createProxy({ order: order.split(','), log: logStream, stateFile, secret });
|
|
599
|
+
} catch (e) {
|
|
600
|
+
console.error(`claude-org: ${e.message}`);
|
|
601
|
+
logStream.end();
|
|
602
|
+
return 1;
|
|
603
|
+
}
|
|
604
|
+
const { server, log, upstream, strategy } = proxy;
|
|
305
605
|
|
|
306
606
|
try {
|
|
307
607
|
await new Promise((resolve, reject) => {
|
|
308
608
|
server.once('error', reject);
|
|
309
609
|
server.listen(0, '127.0.0.1', () => {
|
|
310
|
-
log(`listening on http://127.0.0.1:${server.address().port} orgs=${listOrgs().join(',')} upstream=${upstream.host}`);
|
|
610
|
+
log(`listening on http://127.0.0.1:${server.address().port} orgs=${listOrgs().join(',')} upstream=${upstream.host} strategy=${strategy}`);
|
|
311
611
|
resolve();
|
|
312
612
|
});
|
|
313
613
|
});
|
|
@@ -316,15 +616,16 @@ async function proxyLaunch(args) {
|
|
|
316
616
|
return 1;
|
|
317
617
|
}
|
|
318
618
|
const port = server.address().port;
|
|
319
|
-
console.error(`-> proxy 127.0.0.1:${port} (order: ${order}, log: ${logf})`);
|
|
619
|
+
console.error(`-> proxy 127.0.0.1:${port} (order: ${order}, strategy: ${strategy}, log: ${logf})`);
|
|
320
620
|
|
|
321
621
|
// A custom ANTHROPIC_BASE_URL + AUTH_TOKEN makes Claude Code treat the session
|
|
322
622
|
// as API-key auth: prompt cache TTL drops to 5min and MCP tool search is
|
|
323
623
|
// disabled. Restore subscription-equivalent defaults; user-set values win.
|
|
324
624
|
const envExtra = {
|
|
325
625
|
ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`,
|
|
326
|
-
ANTHROPIC_AUTH_TOKEN:
|
|
626
|
+
ANTHROPIC_AUTH_TOKEN: secret, // sent as Bearer; the proxy checks it, then swaps in an org token
|
|
327
627
|
CLAUDE_ORG_PROXY: '1', // marker: "connector MCPs unavailable" session
|
|
628
|
+
CLAUDE_ORG_STATE_FILE: stateFile, // last org/model, for statusline and hooks
|
|
328
629
|
};
|
|
329
630
|
if (!process.env.CLAUDE_CODE_PROMPT_CACHE_TTL) envExtra.CLAUDE_CODE_PROMPT_CACHE_TTL = '1h';
|
|
330
631
|
if (!process.env.ENABLE_TOOL_SEARCH) envExtra.ENABLE_TOOL_SEARCH = 'true';
|
|
@@ -339,9 +640,22 @@ async function proxyLaunch(args) {
|
|
|
339
640
|
// `node claude-org-proxy.js`).
|
|
340
641
|
if (require.main === module) {
|
|
341
642
|
const PORT = Number(process.env.PORT || 0);
|
|
342
|
-
|
|
643
|
+
let proxy;
|
|
644
|
+
try {
|
|
645
|
+
proxy = createProxy();
|
|
646
|
+
} catch (e) {
|
|
647
|
+
console.error(`claude-org-proxy: ${e.message}`);
|
|
648
|
+
process.exit(1);
|
|
649
|
+
}
|
|
650
|
+
const { server, listOrgs, log, upstream, strategy } = proxy;
|
|
651
|
+
server.once('error', e => {
|
|
652
|
+
console.error(`claude-org-proxy: cannot listen on 127.0.0.1:${PORT}: ${e.message}`);
|
|
653
|
+
process.exit(1);
|
|
654
|
+
});
|
|
343
655
|
server.listen(PORT, '127.0.0.1', () => {
|
|
344
|
-
|
|
656
|
+
let orgs;
|
|
657
|
+
try { orgs = listOrgs().join(','); } catch (e) { orgs = `<cannot list: ${e.message}>`; }
|
|
658
|
+
log(`listening on http://127.0.0.1:${server.address().port} orgs=${orgs} upstream=${upstream.host} strategy=${strategy}`);
|
|
345
659
|
});
|
|
346
660
|
}
|
|
347
661
|
|