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/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 listen port (default 0 = OS picks; reported on the
21
- // "listening on" stderr line)
22
- // CLAUDE_ORG_TOKEN_DIR where <name>.token files live (~/.claude/org-tokens)
23
- // CLAUDE_ORG_ORDER org priority, comma-separated (default: alphabetical)
24
- // CLAUDE_ORG_UPSTREAM forward target (default https://api.anthropic.com)
25
- // CLAUDE_ORG_REJECT_FILE test seam: orgs listed comma-separated in this file
26
- // are treated as rate-limited
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
- // The model name for the log: client input, so limited to safe characters and
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 || 'https://api.anthropic.com');
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 files = fs.readdirSync(TOKEN_DIR).filter(f => f.endsWith('.token')).map(f => f.slice(0, -6)).sort();
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
- return fs.readFileSync(path.join(TOKEN_DIR, `${name}.token`), 'utf8').replace(/[\r\n]/g, '');
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
- return orgs.filter(o => !blocked(o)).concat(orgs.filter(blocked));
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
- function forward(org, method, urlPath, headers, body) {
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 = { ...headers };
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 ${readToken(org)}`;
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
- { hostname: UPSTREAM.hostname, port: UPSTREAM.port || (transport === https ? 443 : 80), path: urlPath, method, headers: h },
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
- const server = http.createServer((clientReq, clientRes) => {
195
- const chunks = [];
196
- clientReq.on('data', c => chunks.push(c));
197
- clientReq.on('end', async () => {
198
- const body = Buffer.concat(chunks);
199
- const isModelReq = clientReq.url.startsWith('/v1/messages');
200
- const model = isModelReq ? requestModel(body) : '';
201
- const order = pickOrder(model);
202
- let attempt = 0;
203
- let modelRefusals = 0;
204
- for (const org of order) {
205
- attempt++;
206
- let up;
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
- up = await forward(org, clientReq.method, clientReq.url, clientReq.headers, body);
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
- const hdr = name => up.headers[`anthropic-ratelimit-unified-${name}`];
214
- const status5h = hdr('5h-status');
215
- const status7d = hdr('7d-status');
216
- const claim = hdr('representative-claim');
217
- // The 5h and 7d windows cover every model of the org, so only those make
218
- // the org as a whole unusable. Other refusals are per model: a used-up
219
- // per-model weekly allowance (claim=seven_day_overage_included) or a model
220
- // that needs usage credits (429 with no window headers). The overall
221
- // "unified-status" header is not used: it also reads "rejected" for those.
222
- const orgWide = status5h === 'rejected' || status7d === 'rejected';
223
- const limited = up.statusCode === 429 || orgWide;
224
- const windows = `5h=${status5h ?? '?'}${claim ? `, claim=${claim}` : ''}`;
225
- // A 429 body is a small JSON error; buffer it to log the reason and,
226
- // if no org is left, to hand it to the client unchanged.
227
- let errBody = null;
228
- let reason = '';
229
- if (isModelReq && up.statusCode === 429) {
230
- try {
231
- errBody = await readBody(up, ERROR_BODY_MAX_BYTES);
232
- } catch (e) {
233
- log(`upstream error via ${org} while reading 429 body: ${e.message}`);
234
- continue;
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 (isModelReq) {
265
- const util = hdr('5h-utilization');
266
- log(`${clientReq.method} ${clientReq.url} via ${org} -> ${up.statusCode} (5h ${status5h ?? '?'} ${util ?? '?'})${reason ? ` ${reason}` : ''}`);
267
- if (modelRefused && modelRefusals === attempt - 1) {
268
- 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`);
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
- clientRes.writeHead(up.statusCode, { ...up.headers, 'x-claude-org': org });
272
- if (errBody) {
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
- clientRes.writeHead(503, { 'content-type': 'application/json' });
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
- const orgs = listOrgs();
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 logf = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'claude-org-proxy-')), 'proxy.log');
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
- const { server, log, upstream } = createProxy({ order: order.split(','), log: logStream });
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: 'claude-org-proxy', // dummy; the proxy replaces it
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
- const { server, listOrgs, log, upstream } = createProxy();
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
- log(`listening on http://127.0.0.1:${server.address().port} orgs=${listOrgs().join(',')} upstream=${upstream.host}`);
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