claude-org 0.2.0 → 0.2.2

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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.2.2 — 2026-10-10
6
+
7
+ - Development: `npm run release -- <patch|minor|major|X.Y.Z>` runs the whole release (checks, tests, CHANGELOG, version, commit, tag, push, npm publish, registry check)
8
+
9
+ ## 0.2.1 — 2026-10-10
10
+
11
+ - `proxy`: a 429 is now classified before failing over (#8). Only a rejected 5h or 7d window takes the whole org out. Any other 429 (a used-up per-model allowance, a model that needs usage credits, a per-minute limit) takes the org out for that model only, for at most 10 minutes or the `retry-after` time, so other models keep using the org
12
+ - `proxy`: the log now shows the reason of each 429 (`error.type: message`, decoded from a brotli/gzip/deflate body) and the rate-limit claim, and says to switch the model when every org refused the same model
13
+ - Windows: launch `claude` without the Node DEP0190 deprecation warning (shell args passed as one quoted command string) (#10)
14
+
3
15
  ## 0.2.0 — 2026-10-03
4
16
 
5
17
  - Add `ccorg` as a short command alias for `claude-org` (same binary)
package/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 miyashita337
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 miyashita337
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -130,6 +130,20 @@ npm test # node:test suite, no real API calls
130
130
  npm pack --dry-run # inspect the published file set
131
131
  ```
132
132
 
133
+ ### Releasing
134
+
135
+ Write the changes under `## Unreleased` in `CHANGELOG.md`, then on an up-to-date `main`:
136
+
137
+ ```sh
138
+ npm login # once per machine
139
+ npm run release -- patch --dry-run # checks + tests only, changes nothing
140
+ npm run release -- patch # or minor / major / X.Y.Z
141
+ ```
142
+
143
+ The script refuses to run off `main`, with uncommitted tracked changes, out of sync with `origin/main`, with an existing tag or registry version, with files npm would publish that git does not track, or without npm login. It then runs the tests, renames `## Unreleased` to the new version heading, bumps `package.json`, commits `chore: release vX.Y.Z`, tags, pushes the commit and tag together (atomic), runs `npm publish` and waits until the registry serves the new version. `--no-publish` stops after the push.
144
+
145
+ Users update with `npm install -g claude-org@latest`.
146
+
133
147
  ## License
134
148
 
135
149
  MIT
package/bin/claude-org.js CHANGED
File without changes
@@ -0,0 +1,16 @@
1
+ 'use strict';
2
+ const { spawn } = require('child_process');
3
+
4
+ // DEP0190: passing an args array with shell:true is deprecated (args are
5
+ // concatenated unescaped). On Windows a shell is still needed to resolve
6
+ // claude.cmd, so build a single quoted command string instead.
7
+ const q = s => `"${String(s).replace(/(["\\$`])/g, '\\$1')}"`;
8
+
9
+ function spawnClaude(args, opts = {}) {
10
+ if (process.platform === 'win32') {
11
+ return spawn(['claude', ...args.map(q)].join(' '), { ...opts, shell: true });
12
+ }
13
+ return spawn('claude', args, opts);
14
+ }
15
+
16
+ module.exports = { spawnClaude, q };
package/lib/launch.js CHANGED
@@ -1,14 +1,13 @@
1
1
  'use strict';
2
- const { spawn } = require('child_process');
2
+ const { spawnClaude } = require('./claude-spawn');
3
3
  const { readToken } = require('./config');
4
4
 
5
5
  // Spawn claude with inherited stdio and extra env. Resolves to the child's
6
6
  // exit code (128+n when killed by signal n, like a shell would report).
7
7
  function runClaude(args, envExtra = {}) {
8
8
  return new Promise(resolve => {
9
- const child = spawn('claude', args, {
9
+ const child = spawnClaude(args, {
10
10
  stdio: 'inherit',
11
- shell: process.platform === 'win32', // resolves claude.cmd on Windows
12
11
  env: { ...process.env, ...envExtra },
13
12
  });
14
13
  child.on('error', e => {
package/lib/proxy.js CHANGED
@@ -6,6 +6,12 @@
6
6
  // with the next org's token. A running Claude Code session never sees the 429,
7
7
  // so the org switches without a restart.
8
8
  //
9
+ // Not every 429 means the org is used up. A model can have its own limit (for
10
+ // example a per-model weekly allowance, or usage credits that the org has not
11
+ // enabled) while the 5h and 7d windows are still "allowed" and other models
12
+ // work. Such a 429 only takes that org out for that model; the org keeps
13
+ // serving other models.
14
+ //
9
15
  // Why a proxy instead of apiKeyHelper: an OAuth long-lived token is accepted as
10
16
  // `Authorization: Bearer` alone, but if `x-api-key` is also present it is checked
11
17
  // first and returns 401. The proxy drops x-api-key and sends Bearer only.
@@ -23,6 +29,88 @@ const https = require('https');
23
29
  const fs = require('fs');
24
30
  const path = require('path');
25
31
  const os = require('os');
32
+ const zlib = require('zlib');
33
+
34
+ // How long an org stays out when it is org-wide limited but sent no reset time,
35
+ // and the longest a model-specific refusal keeps an (org, model) pair out.
36
+ const ORG_REJECT_FALLBACK_MS = 10 * 60 * 1000;
37
+ const MODEL_REJECT_MAX_MS = 10 * 60 * 1000;
38
+ // A real 429 body is well under 1 KiB. These caps keep a misbehaving upstream
39
+ // from exhausting memory: the raw body read, and the decompressed copy.
40
+ const ERROR_BODY_MAX_BYTES = 1024 * 1024;
41
+ const ERROR_DECODED_MAX_BYTES = 64 * 1024;
42
+
43
+ // The "model" field of a /v1/messages request body, or '?' when the body is
44
+ // not JSON (the proxy must still forward it). Used as is as the quarantine
45
+ // key, so two different model names never share a key.
46
+ function requestModel(body) {
47
+ try {
48
+ const m = JSON.parse(body.toString('utf8')).model;
49
+ return typeof m === 'string' && m ? m : '?';
50
+ } catch {
51
+ return '?';
52
+ }
53
+ }
54
+
55
+ // The model name for the log: client input, so limited to safe characters and
56
+ // cut short.
57
+ function logModel(model) {
58
+ return model.replace(/[^\w.:@/-]/g, '_').slice(0, 80);
59
+ }
60
+
61
+ // Milliseconds from a Retry-After header, which is either delay-seconds or an
62
+ // HTTP-date. NaN when absent or unparsable.
63
+ function retryAfterMs(value) {
64
+ if (value === undefined || value === '') return NaN;
65
+ const secs = Number(value);
66
+ if (Number.isFinite(secs)) return secs >= 0 ? secs * 1000 : NaN;
67
+ const at = Date.parse(value);
68
+ return Number.isFinite(at) ? Math.max(0, at - Date.now()) : NaN;
69
+ }
70
+
71
+ function readBody(res, maxBytes) {
72
+ return new Promise((resolve, reject) => {
73
+ const chunks = [];
74
+ let size = 0;
75
+ res.on('data', c => {
76
+ size += c.length;
77
+ if (size > maxBytes) {
78
+ res.destroy();
79
+ reject(new Error(`429 body larger than ${maxBytes} bytes`));
80
+ return;
81
+ }
82
+ chunks.push(c);
83
+ });
84
+ res.on('end', () => resolve(Buffer.concat(chunks)));
85
+ res.on('error', reject);
86
+ });
87
+ }
88
+
89
+ // The API compresses error bodies (brotli was observed on 2026-10-10) when the
90
+ // client sent accept-encoding. Decode a copy for the log; the client still
91
+ // gets the original bytes with the original content-encoding header.
92
+ // maxOutputLength makes a decompression bomb throw instead of filling memory.
93
+ function decodeBody(buf, encoding) {
94
+ const opts = { maxOutputLength: ERROR_DECODED_MAX_BYTES };
95
+ switch ((encoding || '').trim().toLowerCase()) {
96
+ case 'br': return zlib.brotliDecompressSync(buf, opts);
97
+ case 'gzip': case 'x-gzip': return zlib.gunzipSync(buf, opts);
98
+ case 'deflate': return zlib.inflateSync(buf, opts);
99
+ default: return buf;
100
+ }
101
+ }
102
+
103
+ // "error.type: error.message" from an API error body, for the log line. The
104
+ // message is cut short; the full body still reaches the client.
105
+ function describeError(buf, encoding) {
106
+ try {
107
+ const e = JSON.parse(decodeBody(buf, encoding).toString('utf8')).error || {};
108
+ const msg = String(e.message || '').replace(/\s+/g, ' ').slice(0, 160);
109
+ return msg ? `${e.type || 'unknown_error'}: ${msg}` : (e.type || 'unknown_error');
110
+ } catch {
111
+ return 'non-JSON 429 body';
112
+ }
113
+ }
26
114
 
27
115
  function createProxy(opts = {}) {
28
116
  const TOKEN_DIR = opts.tokenDir || process.env.CLAUDE_ORG_TOKEN_DIR
@@ -59,6 +147,15 @@ function createProxy(opts = {}) {
59
147
  return false;
60
148
  }
61
149
  }
150
+ // Per-(org, model) memory for model-specific refusals: "org|model" -> epoch (ms).
151
+ const modelRejectedUntil = new Map();
152
+ function isModelRejected(name, model) {
153
+ const key = `${name}|${model}`;
154
+ const until = modelRejectedUntil.get(key);
155
+ if (until === undefined) return false;
156
+ if (Date.now() >= until) { modelRejectedUntil.delete(key); return false; }
157
+ return true;
158
+ }
62
159
  function isRejected(name) {
63
160
  if (fileRejected(name)) return true;
64
161
  const until = rejectedUntil.get(name);
@@ -68,9 +165,10 @@ function createProxy(opts = {}) {
68
165
  }
69
166
  // Usable orgs first, in priority order; rate-limited orgs are moved to the
70
167
  // tail (kept as a last resort when everything is limited).
71
- function pickOrder() {
168
+ function pickOrder(model) {
72
169
  const orgs = listOrgs();
73
- return orgs.filter(o => !isRejected(o)).concat(orgs.filter(o => isRejected(o)));
170
+ const blocked = o => isRejected(o) || isModelRejected(o, model);
171
+ return orgs.filter(o => !blocked(o)).concat(orgs.filter(blocked));
74
172
  }
75
173
 
76
174
  function forward(org, method, urlPath, headers, body) {
@@ -99,8 +197,10 @@ function createProxy(opts = {}) {
99
197
  clientReq.on('end', async () => {
100
198
  const body = Buffer.concat(chunks);
101
199
  const isModelReq = clientReq.url.startsWith('/v1/messages');
102
- const order = pickOrder();
200
+ const model = isModelReq ? requestModel(body) : '';
201
+ const order = pickOrder(model);
103
202
  let attempt = 0;
203
+ let modelRefusals = 0;
104
204
  for (const org of order) {
105
205
  attempt++;
106
206
  let up;
@@ -110,21 +210,70 @@ function createProxy(opts = {}) {
110
210
  log(`upstream error via ${org}: ${e.message}`);
111
211
  continue;
112
212
  }
113
- const status5h = up.headers['anthropic-ratelimit-unified-5h-status'];
114
- const reset5h = Number(up.headers['anthropic-ratelimit-unified-5h-reset']);
115
- const limited = up.statusCode === 429 || status5h === 'rejected';
116
- if (limited && isModelReq && attempt < order.length) {
117
- rejectedUntil.set(org, Number.isFinite(reset5h) ? reset5h * 1000 : Date.now() + 10 * 60 * 1000);
118
- log(`${org} is rate-limited (HTTP ${up.statusCode}, 5h=${status5h ?? '?'}) -> retrying with next org`);
119
- up.resume(); // discard the response body and try the next org
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
120
262
  continue;
121
263
  }
122
264
  if (isModelReq) {
123
- const util = up.headers['anthropic-ratelimit-unified-5h-utilization'];
124
- log(`${clientReq.method} ${clientReq.url} via ${org} -> ${up.statusCode} (5h ${status5h ?? '?'} ${util ?? '?'})`);
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
+ }
125
270
  }
126
271
  clientRes.writeHead(up.statusCode, { ...up.headers, 'x-claude-org': org });
127
- up.pipe(clientRes); // SSE streaming is piped through untouched
272
+ if (errBody) {
273
+ clientRes.end(errBody);
274
+ } else {
275
+ up.pipe(clientRes); // SSE streaming is piped through untouched
276
+ }
128
277
  return;
129
278
  }
130
279
  clientRes.writeHead(503, { 'content-type': 'application/json' });
package/lib/status.js CHANGED
@@ -1,5 +1,5 @@
1
1
  'use strict';
2
- const { spawn } = require('child_process');
2
+ const { spawnClaude } = require('./claude-spawn');
3
3
  const { listOrgs, readToken } = require('./config');
4
4
  const { probe } = require('./probe');
5
5
 
@@ -10,8 +10,7 @@ const KEYS_RE_DEFAULT = /"(loggedIn|authMethod|orgName|subscriptionType)"/;
10
10
  // Run `claude auth status` and return combined stdout+stderr (like 2>&1).
11
11
  function authStatus(envExtra = {}) {
12
12
  return new Promise(resolve => {
13
- const child = spawn('claude', ['auth', 'status'], {
14
- shell: process.platform === 'win32',
13
+ const child = spawnClaude(['auth', 'status'], {
15
14
  env: { ...process.env, ...envExtra },
16
15
  });
17
16
  const chunks = [];
package/lib/watch.js CHANGED
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
  const { spawn } = require('child_process');
3
+ const { q } = require('./claude-spawn');
3
4
  const { resolvePrefer } = require('./config');
4
5
  const { probe } = require('./probe');
5
6
  const { hhmmss } = require('./time');
@@ -16,7 +17,6 @@ function notify(title, message) {
16
17
  }
17
18
  // Quote args ourselves: spawn(shell:true) does not quote on Windows and the
18
19
  // title/message contain spaces.
19
- const q = s => `"${String(s).replace(/(["\\$`])/g, '\\$1')}"`;
20
20
  return new Promise(resolve => {
21
21
  const child = spawn(`${cmd} ${q(title)} ${q(message)}`, { shell: true, stdio: 'ignore' });
22
22
  child.on('error', () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-org",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Switch Claude Code between multiple Anthropic organizations, with automatic failover on the 5-hour rate limit",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -19,7 +19,8 @@
19
19
  "CHANGELOG.md"
20
20
  ],
21
21
  "scripts": {
22
- "test": "node --test"
22
+ "test": "node --test",
23
+ "release": "node scripts/release.js"
23
24
  },
24
25
  "keywords": [
25
26
  "claude-code",