claude-org 0.2.0 → 0.3.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 +19 -0
- package/README.md +45 -3
- package/bin/claude-org.js +21 -3
- package/lib/claude-spawn.js +16 -0
- package/lib/launch.js +2 -3
- package/lib/pick.js +9 -4
- package/lib/probe.js +90 -29
- package/lib/proxy.js +239 -20
- package/lib/status.js +5 -5
- package/lib/usage.js +7 -3
- package/lib/watch.js +1 -1
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.3.0 — 2026-10-10
|
|
6
|
+
|
|
7
|
+
- `proxy`: each log line now names the model (`via <org> model=<model> -> <status>`), and the org and model of the last request are written to a JSON state file whose path is passed to `claude` as `CLAUDE_ORG_STATE_FILE`, for statusline and hooks (#12)
|
|
8
|
+
- `probe` / `status`: `--model <id>` (or `CLAUDE_ORG_PROBE_MODEL`) checks that model instead of Haiku and appends `model_status=available|unavailable|unknown` (and `model_error=` when refused), so "5h allowed but this model is refused" is told apart. `auto` falls back to the default org when the preferred org refuses the model (#9)
|
|
9
|
+
- `proxy`: `CLAUDE_ORG_STRATEGY=least-utilized` sends each request to the usable org with the lowest 5h utilization it has reported (orgs not yet measured are tried first). Unset keeps the priority order; the launch line and the log now show the strategy (#7)
|
|
10
|
+
- Docs: proxy mode now explains that Agent-tool subagents go through the same proxy and what that implies (#16)
|
|
11
|
+
|
|
12
|
+
## 0.2.2 — 2026-10-10
|
|
13
|
+
|
|
14
|
+
- 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)
|
|
15
|
+
|
|
16
|
+
## 0.2.1 — 2026-10-10
|
|
17
|
+
|
|
18
|
+
- `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
|
|
19
|
+
- `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
|
|
20
|
+
- Windows: launch `claude` without the Node DEP0190 deprecation warning (shell args passed as one quoted command string) (#10)
|
|
21
|
+
|
|
3
22
|
## 0.2.0 — 2026-10-03
|
|
4
23
|
|
|
5
24
|
- Add `ccorg` as a short command alias for `claude-org` (same binary)
|
package/README.md
CHANGED
|
@@ -57,13 +57,22 @@ Repeat steps 1–2 **once per org**:
|
|
|
57
57
|
| `claude-org proxy [claude args...]` | Launch via a local proxy: a **running** session fails over to another org the moment the 5h limit hits. Needs 2+ org tokens (incl. one for the default side). |
|
|
58
58
|
| `claude-org auto [claude args...]` | Launch on the preferred org if usable, else on the default org. Decided once at launch. |
|
|
59
59
|
| `claude-org <name> [claude args...]` | Launch with that org's token, e.g. `claude-org work --resume`. |
|
|
60
|
-
| `claude-org probe [<name>]` | Print the org's 5h window state on one line (`status= 5h_util= 5h_reset= 5h_reset_local=`). Exit 0 when `allowed`. |
|
|
60
|
+
| `claude-org probe [<name>] [--model <id>]` | Print the org's 5h window state on one line (`status= 5h_util= 5h_reset= 5h_reset_local=`). Exit 0 when `allowed`. With `--model`, probe with that model and append `model= model_status=` (see below). |
|
|
61
61
|
| `claude-org watch` | Poll the preferred org until its window recovers, then notify (terminal + optional `CLAUDE_ORG_NOTIFY_CMD`). |
|
|
62
|
-
| `claude-org status` | `claude auth status` for every saved token + the default login. |
|
|
62
|
+
| `claude-org status [--model <id>]` | `claude auth status` and `probe` for every saved token + the default login. |
|
|
63
63
|
| `claude-org list` | List saved org names. |
|
|
64
64
|
|
|
65
65
|
`auto` and `<name>` only read env at launch — they cannot switch a running session. Settings, plugins, and session history live under `~/.claude/` and are shared across orgs, so `claude-org auto --resume` continues a session started under the other org.
|
|
66
66
|
|
|
67
|
+
**Checking a model, not just the window**: `probe` and `status` use Haiku by default, but an org can show `status=allowed` and still refuse another model (for example one that needs usage credits). Pass the model your session uses:
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
claude-org probe work --model claude-fable-5-1
|
|
71
|
+
# status=allowed 5h_util=0.34 ... model=claude-fable-5-1 model_status=unavailable model_error=rate_limit_error
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`model_status` is `available` (2xx), `unavailable` (refused while the 5h/7d windows are not rejected; exit 1) or `unknown` (no response, bad token or permission, timeout, server error, or a rejected window; the exit code then follows `status` alone). Setting `CLAUDE_ORG_PROBE_MODEL` applies the same check to `probe`, `status` and `auto`; `auto` then launches on the default org when the preferred org refuses that model.
|
|
75
|
+
|
|
67
76
|
The **preferred org** (used by `auto`, `watch`, and as `proxy`'s first candidate) resolves in this order: `CLAUDE_ORG_PREFER` -> first entry of `CLAUDE_ORG_ORDER` -> first `.token` file alphabetically.
|
|
68
77
|
|
|
69
78
|
## proxy mode
|
|
@@ -77,13 +86,29 @@ claude-org proxy --resume
|
|
|
77
86
|
3. The proxy replaces the `Authorization` header with a real org token and forwards to `api.anthropic.com`. On a 429 or `anthropic-ratelimit-unified-5h-status: rejected` it marks that org limited until `5h-reset` and **resends the same request** with the next org's token.
|
|
78
87
|
4. Claude Code only ever sees the successful response — the org switched mid-session. SSE streaming is piped through untouched.
|
|
79
88
|
|
|
80
|
-
One log line per request (`via <org> -> <status>`) goes to a temp file printed at launch (`-> proxy 127.0.0.1:<port> (order: ..., log: ...)`).
|
|
89
|
+
One log line per request (`via <org> model=<model> -> <status>`) goes to a temp file printed at launch (`-> proxy 127.0.0.1:<port> (order: ..., strategy: ..., log: ...)`). The prompt and the tokens are never logged.
|
|
90
|
+
|
|
91
|
+
**Spreading load by utilization**: with `CLAUDE_ORG_STRATEGY=least-utilized`, the proxy remembers the `5h-utilization` each org last reported and sends each request to the usable org with the lowest value. An org that has not reported yet is tried first (in priority order) so it gets measured once; one that answers without a utilization goes after the measured ones. A value is forgotten when that org's 5h window resets. Rate-limited orgs are still skipped. Utilization is a share of each org's own plan, not an absolute amount, so a larger plan is picked more often; if you want one org's allowance used up first (e.g. your company's), keep the default and set `CLAUDE_ORG_ORDER` instead.
|
|
92
|
+
|
|
93
|
+
**Which org is in use right now**: after each model request the proxy writes `{"org", "model", "status", "at"}` as JSON (`org` is `null` with `status` 503 when no org could serve) to `state.json` in the same temp directory as the log. Its path is exported to the `claude` process as `CLAUDE_ORG_STATE_FILE`, so a statusline command or hook can show it, e.g.:
|
|
94
|
+
|
|
95
|
+
```sh
|
|
96
|
+
[ -n "$CLAUDE_ORG_STATE_FILE" ] && [ -f "$CLAUDE_ORG_STATE_FILE" ] && node -p "require(process.env.CLAUDE_ORG_STATE_FILE).org"
|
|
97
|
+
```
|
|
81
98
|
|
|
82
99
|
**Limitations of proxy mode**
|
|
83
100
|
|
|
84
101
|
- claude.ai connector MCPs (Gmail / Drive / Calendar / Notion / Slack …) and Remote Control are **unavailable**: `ANTHROPIC_AUTH_TOKEN` makes Claude Code treat the session as external-token auth, so `mcp__claude_ai_*` tools do not appear. Local/plugin MCPs work. Open connectors in a plain `claude --resume` session — history is shared.
|
|
85
102
|
- Each proxy serves one session and dies when `claude` exits.
|
|
86
103
|
|
|
104
|
+
**Subagents**
|
|
105
|
+
|
|
106
|
+
- Subagents started with Claude Code's Agent tool run in the same process as the parent session and share its `ANTHROPIC_BASE_URL`, so they go through the same proxy with no setup and use the same orgs' limits. Their requests appear in the proxy log with their own `model=` (e.g. a Haiku subagent).
|
|
107
|
+
- Not covered: a `claude` started as a separate process (it needs its own `claude-org proxy`), and agents that run in the cloud (e.g. `isolation: remote`).
|
|
108
|
+
- The connector MCP limitation above applies to subagents too.
|
|
109
|
+
- When an org's 5h or 7d window is rejected, the org is taken out for every model, so subagents also move to the next org until the window resets. A 429 for one model only (#8) moves that model only; other models, including those of subagents, stay on the org.
|
|
110
|
+
- Work routed to a Team / Enterprise org (e.g. your company's) counts toward that org's usage, which its admins can see.
|
|
111
|
+
|
|
87
112
|
### Prompt cache & tool search
|
|
88
113
|
|
|
89
114
|
A custom `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN` makes Claude Code treat the session as API-key auth, which changes two defaults:
|
|
@@ -105,11 +130,14 @@ A custom `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN` makes Claude Code treat t
|
|
|
105
130
|
|---|---|---|
|
|
106
131
|
| `CLAUDE_ORG_PREFER` | (unset) | Preferred org for `auto` / `watch` / `proxy` |
|
|
107
132
|
| `CLAUDE_ORG_ORDER` | (unset) | Comma-separated org priority for `proxy`; unlisted orgs follow alphabetically |
|
|
133
|
+
| `CLAUDE_ORG_STRATEGY` | (unset: priority order) | `least-utilized`: `proxy` sends each request to the usable org with the lowest 5h utilization (see proxy mode) |
|
|
134
|
+
| `CLAUDE_ORG_PROBE_MODEL` | (unset: Haiku) | Model used by `probe` / `status` / `auto` to check the org; adds `model_status=` to the probe line |
|
|
108
135
|
| `CLAUDE_ORG_TOKEN_DIR` | `~/.claude/org-tokens` | Where `<name>.token` files live |
|
|
109
136
|
| `CLAUDE_ORG_NOTIFY_CMD` | (unset) | `watch` notification command, run as `<cmd> "<title>" "<message>"` via the OS shell |
|
|
110
137
|
| `CLAUDE_ORG_WATCH_MAX_SLEEP` | `18000` | Max seconds between `watch` re-checks (values < 60 also lower the 60s minimum — for tests) |
|
|
111
138
|
| `CLAUDE_ORG_UPSTREAM` | `https://api.anthropic.com` | API base URL for `probe` and `proxy` (stub upstream in tests) |
|
|
112
139
|
| `CLAUDE_ORG_PROXY` | `1` when launched via `proxy` | Marker env passed to the claude process so user hooks can detect connector-less sessions |
|
|
140
|
+
| `CLAUDE_ORG_STATE_FILE` | set when launched via `proxy` | Path of the JSON file holding the org and model of the last request (read it from a statusline or hook) |
|
|
113
141
|
| `CLAUDE_ORG_FORCE_PROBE` | (unset) | Test seam: `allowed` / `rejected[:<epoch>]` / `rejected_until:<epoch>` fakes `probe` results without API calls |
|
|
114
142
|
| `CLAUDE_ORG_REJECT_FILE` | (unset) | Test seam: orgs listed comma-separated in this file are treated as rate-limited by `proxy` |
|
|
115
143
|
|
|
@@ -130,6 +158,20 @@ npm test # node:test suite, no real API calls
|
|
|
130
158
|
npm pack --dry-run # inspect the published file set
|
|
131
159
|
```
|
|
132
160
|
|
|
161
|
+
### Releasing
|
|
162
|
+
|
|
163
|
+
Write the changes under `## Unreleased` in `CHANGELOG.md`, then on an up-to-date `main`:
|
|
164
|
+
|
|
165
|
+
```sh
|
|
166
|
+
npm login # once per machine
|
|
167
|
+
npm run release -- patch --dry-run # checks + tests only, changes nothing
|
|
168
|
+
npm run release -- patch # or minor / major / X.Y.Z
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
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.
|
|
172
|
+
|
|
173
|
+
Users update with `npm install -g claude-org@latest`.
|
|
174
|
+
|
|
133
175
|
## License
|
|
134
176
|
|
|
135
177
|
MIT
|
package/bin/claude-org.js
CHANGED
|
@@ -10,6 +10,23 @@ const { statusAll } = require('../lib/status');
|
|
|
10
10
|
const { watch } = require('../lib/watch');
|
|
11
11
|
const { proxyLaunch } = require('../lib/proxy');
|
|
12
12
|
|
|
13
|
+
// `--model <id>` / `--model=<id>` for probe and status. Returns the model
|
|
14
|
+
// ('' when absent) and the remaining args. A missing value is an error rather
|
|
15
|
+
// than a silent fall back to the default model.
|
|
16
|
+
function takeModel(args) {
|
|
17
|
+
const rest = [];
|
|
18
|
+
let model = '';
|
|
19
|
+
for (let i = 0; i < args.length; i++) {
|
|
20
|
+
if (args[i] === '--model' || args[i].startsWith('--model=')) {
|
|
21
|
+
model = args[i] === '--model' ? args[++i] : args[i].slice(8);
|
|
22
|
+
if (!model || model.startsWith('-')) throw new Error('--model needs a model id, e.g. --model claude-fable-5-1');
|
|
23
|
+
} else {
|
|
24
|
+
rest.push(args[i]);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return { model, rest };
|
|
28
|
+
}
|
|
29
|
+
|
|
13
30
|
async function main() {
|
|
14
31
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
15
32
|
switch (cmd) {
|
|
@@ -23,11 +40,12 @@ async function main() {
|
|
|
23
40
|
for (const o of listOrgs()) console.log(o);
|
|
24
41
|
return 0;
|
|
25
42
|
case 'status':
|
|
26
|
-
return statusAll();
|
|
43
|
+
return statusAll(takeModel(rest).model || undefined);
|
|
27
44
|
case 'probe': {
|
|
28
|
-
const
|
|
45
|
+
const { model, rest: names } = takeModel(rest);
|
|
46
|
+
const r = await probe(names[0] || resolvePrefer(), model || undefined);
|
|
29
47
|
if (r.line) console.log(r.line);
|
|
30
|
-
return r.
|
|
48
|
+
return r.usable ? 0 : 1;
|
|
31
49
|
}
|
|
32
50
|
case 'pick':
|
|
33
51
|
console.log(await pick());
|
|
@@ -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 {
|
|
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 =
|
|
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/pick.js
CHANGED
|
@@ -3,18 +3,23 @@ const { resolvePrefer } = require('./config');
|
|
|
3
3
|
const { probe } = require('./probe');
|
|
4
4
|
|
|
5
5
|
// pick(): which org `auto` should launch on. Returns 'default' when the
|
|
6
|
-
// preferred org is over its 5h limit (or its token is missing)
|
|
7
|
-
// reason goes to stderr as a
|
|
6
|
+
// preferred org is over its 5h limit (or its token is missing), or refused the
|
|
7
|
+
// model set in CLAUDE_ORG_PROBE_MODEL. The decision reason goes to stderr as a
|
|
8
|
+
// single `-> ...` line.
|
|
8
9
|
async function pick() {
|
|
9
10
|
const prefer = resolvePrefer();
|
|
10
11
|
const r = await probe(prefer);
|
|
11
|
-
if (r.
|
|
12
|
+
if (r.usable) {
|
|
12
13
|
let util = r.util;
|
|
13
14
|
if (util !== '?') util = `${Math.trunc(Number(util) * 100)}%`;
|
|
14
15
|
console.error(`-> ${prefer} (5h ${util} used)`);
|
|
15
16
|
return prefer;
|
|
16
17
|
}
|
|
17
|
-
|
|
18
|
+
if (r.allowed) {
|
|
19
|
+
console.error(`-> default (${prefer} refused model ${r.model})`);
|
|
20
|
+
} else {
|
|
21
|
+
console.error(`-> default (${prefer} is ${r.status}, resets ${r.resetLocal})`);
|
|
22
|
+
}
|
|
18
23
|
return 'default';
|
|
19
24
|
}
|
|
20
25
|
|
package/lib/probe.js
CHANGED
|
@@ -2,18 +2,78 @@
|
|
|
2
2
|
const { readToken } = require('./config');
|
|
3
3
|
const { epochToHHMM } = require('./time');
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
//
|
|
5
|
+
const DEFAULT_MODEL = 'claude-haiku-4-5-20251001';
|
|
6
|
+
|
|
7
|
+
// Whether the org served the probed model. The 5h window is shared by every
|
|
8
|
+
// model, but a model can still be refused while it reads "allowed" (it needs
|
|
9
|
+
// usage credits, or its own weekly allowance is used up), so this is judged
|
|
10
|
+
// from the HTTP status, not from the window headers.
|
|
11
|
+
// available 2xx
|
|
12
|
+
// unavailable any other 4xx, while neither the 5h nor the 7d window is
|
|
13
|
+
// rejected
|
|
14
|
+
// unknown no response, 401/403 (token or org permission), 408, 5xx, or
|
|
15
|
+
// a rejected window (which hides whether the model itself
|
|
16
|
+
// would be served)
|
|
17
|
+
function judgeModel(res) {
|
|
18
|
+
if (!res) return 'unknown';
|
|
19
|
+
if (res.ok) return 'available';
|
|
20
|
+
const h = name => res.headers.get(`anthropic-ratelimit-unified-${name}`);
|
|
21
|
+
if (h('5h-status') === 'rejected' || h('7d-status') === 'rejected') return 'unknown';
|
|
22
|
+
if ([401, 403, 408].includes(res.status) || res.status >= 500 || res.status < 400) return 'unknown';
|
|
23
|
+
return 'unavailable';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// One 1-token request. Returns { res, errorType }: res is null when no response
|
|
27
|
+
// came; errorType is error.type of a non-2xx JSON body ('' otherwise).
|
|
28
|
+
async function send(tok, model) {
|
|
29
|
+
const base = (process.env.CLAUDE_ORG_UPSTREAM || 'https://api.anthropic.com').replace(/\/+$/, '');
|
|
30
|
+
try {
|
|
31
|
+
const res = await fetch(`${base}/v1/messages`, {
|
|
32
|
+
method: 'POST',
|
|
33
|
+
headers: {
|
|
34
|
+
authorization: `Bearer ${tok}`,
|
|
35
|
+
'anthropic-beta': 'oauth-2025-04-20',
|
|
36
|
+
'anthropic-version': '2023-06-01',
|
|
37
|
+
'content-type': 'application/json',
|
|
38
|
+
},
|
|
39
|
+
body: JSON.stringify({ model, max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),
|
|
40
|
+
signal: AbortSignal.timeout(20000),
|
|
41
|
+
});
|
|
42
|
+
let errorType = '';
|
|
43
|
+
if (res.ok) {
|
|
44
|
+
res.body && res.body.cancel().catch(() => {});
|
|
45
|
+
} else {
|
|
46
|
+
try {
|
|
47
|
+
errorType = String(JSON.parse(await res.text()).error.type || '').replace(/[^\w.-]/g, '_').slice(0, 60);
|
|
48
|
+
} catch {
|
|
49
|
+
errorType = ''; // not a JSON error body: the HTTP status still decides model_status
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return { res, errorType };
|
|
53
|
+
} catch {
|
|
54
|
+
return { res: null, errorType: '' };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// probe(name, model): send a 1-token inference request and read the unified
|
|
59
|
+
// 5h rate-limit headers. Returns { line, status, util, reset, resetLocal,
|
|
60
|
+
// allowed, model, modelStatus, usable }. `line` is the canonical one-line output:
|
|
8
61
|
// status=<allowed|rejected|unknown> 5h_util=<0.xx|?> 5h_reset=<epoch|?> 5h_reset_local=<HH:MM|?>
|
|
62
|
+
// When a model is given (argument, else CLAUDE_ORG_PROBE_MODEL), the request
|
|
63
|
+
// uses it and the line ends with ` model=<id> model_status=<see judgeModel>`,
|
|
64
|
+
// plus ` model_error=<error.type>` when the model was refused.
|
|
65
|
+
// Without one the request uses Haiku and the line is unchanged. `usable` is
|
|
66
|
+
// "allowed and the model was not refused".
|
|
9
67
|
//
|
|
10
68
|
// Test seam: CLAUDE_ORG_FORCE_PROBE = allowed | rejected[:<epoch>] |
|
|
11
69
|
// rejected_until:<epoch> (rejected_until flips to allowed once <epoch> passes).
|
|
12
70
|
// When forced, the token file is not required and no request is sent.
|
|
13
71
|
// CLAUDE_ORG_UPSTREAM overrides the API base URL (stub upstream in tests).
|
|
14
|
-
async function probe(name) {
|
|
72
|
+
async function probe(name, model = process.env.CLAUDE_ORG_PROBE_MODEL || '') {
|
|
15
73
|
const forced = process.env.CLAUDE_ORG_FORCE_PROBE;
|
|
16
74
|
let status, util, reset;
|
|
75
|
+
let modelStatus = 'unknown';
|
|
76
|
+
let modelError = '';
|
|
17
77
|
if (forced) {
|
|
18
78
|
const idx = forced.indexOf(':');
|
|
19
79
|
status = idx === -1 ? forced : forced.slice(0, idx);
|
|
@@ -24,29 +84,26 @@ async function probe(name) {
|
|
|
24
84
|
util = '?';
|
|
25
85
|
} else {
|
|
26
86
|
const tok = readToken(name);
|
|
27
|
-
if (tok === null)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
res.body && res.body.cancel().catch(() => {});
|
|
48
|
-
} catch {
|
|
49
|
-
headers = null;
|
|
87
|
+
if (tok === null) {
|
|
88
|
+
return { line: null, status: 'unknown', util: '?', reset: '?', resetLocal: '?', allowed: false, model, modelStatus, usable: false };
|
|
89
|
+
}
|
|
90
|
+
const first = await send(tok, model || DEFAULT_MODEL);
|
|
91
|
+
let headers = first.res && first.res.headers;
|
|
92
|
+
modelStatus = judgeModel(first.res);
|
|
93
|
+
if (modelStatus === 'unavailable') modelError = first.errorType;
|
|
94
|
+
// A refusal for the model itself carried no window headers on 2026-10-10
|
|
95
|
+
// (Fable 5.1 on an org without usage credits), so the 5h state is read
|
|
96
|
+
// from a second request with the default model.
|
|
97
|
+
if (model && modelStatus === 'unavailable' && !headers.get('anthropic-ratelimit-unified-5h-status')) {
|
|
98
|
+
const second = await send(tok, DEFAULT_MODEL);
|
|
99
|
+
headers = second.res && second.res.headers;
|
|
100
|
+
// A rejected window there means the first 429 may have been the window,
|
|
101
|
+
// not the model, so the model cannot be judged.
|
|
102
|
+
const w = name => headers && headers.get(`anthropic-ratelimit-unified-${name}`);
|
|
103
|
+
if (w('5h-status') === 'rejected' || w('7d-status') === 'rejected') {
|
|
104
|
+
modelStatus = 'unknown';
|
|
105
|
+
modelError = '';
|
|
106
|
+
}
|
|
50
107
|
}
|
|
51
108
|
status = (headers && (headers.get('anthropic-ratelimit-unified-5h-status')
|
|
52
109
|
|| headers.get('anthropic-ratelimit-unified-status'))) || 'unknown';
|
|
@@ -54,8 +111,12 @@ async function probe(name) {
|
|
|
54
111
|
reset = (headers && headers.get('anthropic-ratelimit-unified-5h-reset')) || '?';
|
|
55
112
|
}
|
|
56
113
|
const resetLocal = reset !== '?' ? epochToHHMM(reset) : '?';
|
|
57
|
-
|
|
58
|
-
|
|
114
|
+
let line = `status=${status} 5h_util=${util} 5h_reset=${reset} 5h_reset_local=${resetLocal}`;
|
|
115
|
+
// The model is user input; keep the key=value line parseable.
|
|
116
|
+
if (model) line += ` model=${model.replace(/[^\w.:@/-]/g, '_')} model_status=${modelStatus}${modelError ? ` model_error=${modelError}` : ''}`;
|
|
117
|
+
const allowed = status === 'allowed';
|
|
118
|
+
const usable = allowed && !(model && modelStatus === 'unavailable');
|
|
119
|
+
return { line, status, util, reset, resetLocal, allowed, model, modelStatus, usable };
|
|
59
120
|
}
|
|
60
121
|
|
|
61
122
|
module.exports = { probe };
|
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.
|
|
@@ -15,14 +21,100 @@
|
|
|
15
21
|
// "listening on" stderr line)
|
|
16
22
|
// CLAUDE_ORG_TOKEN_DIR where <name>.token files live (~/.claude/org-tokens)
|
|
17
23
|
// CLAUDE_ORG_ORDER org priority, comma-separated (default: alphabetical)
|
|
24
|
+
// CLAUDE_ORG_STRATEGY "least-utilized": prefer the usable org with the
|
|
25
|
+
// lowest 5h utilization seen so far (default: ORDER)
|
|
18
26
|
// CLAUDE_ORG_UPSTREAM forward target (default https://api.anthropic.com)
|
|
19
27
|
// CLAUDE_ORG_REJECT_FILE test seam: orgs listed comma-separated in this file
|
|
20
28
|
// are treated as rate-limited
|
|
29
|
+
// CLAUDE_ORG_STATE_FILE where the org and model of the last /v1/messages
|
|
30
|
+
// response are written as JSON (unset: not written)
|
|
21
31
|
const http = require('http');
|
|
22
32
|
const https = require('https');
|
|
23
33
|
const fs = require('fs');
|
|
24
34
|
const path = require('path');
|
|
25
35
|
const os = require('os');
|
|
36
|
+
const zlib = require('zlib');
|
|
37
|
+
|
|
38
|
+
// How long an org stays out when it is org-wide limited but sent no reset time,
|
|
39
|
+
// and the longest a model-specific refusal keeps an (org, model) pair out.
|
|
40
|
+
const ORG_REJECT_FALLBACK_MS = 10 * 60 * 1000;
|
|
41
|
+
const MODEL_REJECT_MAX_MS = 10 * 60 * 1000;
|
|
42
|
+
// A real 429 body is well under 1 KiB. These caps keep a misbehaving upstream
|
|
43
|
+
// from exhausting memory: the raw body read, and the decompressed copy.
|
|
44
|
+
const ERROR_BODY_MAX_BYTES = 1024 * 1024;
|
|
45
|
+
const ERROR_DECODED_MAX_BYTES = 64 * 1024;
|
|
46
|
+
|
|
47
|
+
// The "model" field of a /v1/messages request body, or '?' when the body is
|
|
48
|
+
// not JSON (the proxy must still forward it). Used as is as the quarantine
|
|
49
|
+
// key, so two different model names never share a key.
|
|
50
|
+
function requestModel(body) {
|
|
51
|
+
try {
|
|
52
|
+
const m = JSON.parse(body.toString('utf8')).model;
|
|
53
|
+
return typeof m === 'string' && m ? m : '?';
|
|
54
|
+
} catch {
|
|
55
|
+
return '?';
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// The model name for the log: client input, so limited to safe characters and
|
|
60
|
+
// cut short.
|
|
61
|
+
function logModel(model) {
|
|
62
|
+
return model.replace(/[^\w.:@/-]/g, '_').slice(0, 80);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Milliseconds from a Retry-After header, which is either delay-seconds or an
|
|
66
|
+
// HTTP-date. NaN when absent or unparsable.
|
|
67
|
+
function retryAfterMs(value) {
|
|
68
|
+
if (value === undefined || value === '') return NaN;
|
|
69
|
+
const secs = Number(value);
|
|
70
|
+
if (Number.isFinite(secs)) return secs >= 0 ? secs * 1000 : NaN;
|
|
71
|
+
const at = Date.parse(value);
|
|
72
|
+
return Number.isFinite(at) ? Math.max(0, at - Date.now()) : NaN;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function readBody(res, maxBytes) {
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
const chunks = [];
|
|
78
|
+
let size = 0;
|
|
79
|
+
res.on('data', c => {
|
|
80
|
+
size += c.length;
|
|
81
|
+
if (size > maxBytes) {
|
|
82
|
+
res.destroy();
|
|
83
|
+
reject(new Error(`429 body larger than ${maxBytes} bytes`));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
chunks.push(c);
|
|
87
|
+
});
|
|
88
|
+
res.on('end', () => resolve(Buffer.concat(chunks)));
|
|
89
|
+
res.on('error', reject);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// The API compresses error bodies (brotli was observed on 2026-10-10) when the
|
|
94
|
+
// client sent accept-encoding. Decode a copy for the log; the client still
|
|
95
|
+
// gets the original bytes with the original content-encoding header.
|
|
96
|
+
// maxOutputLength makes a decompression bomb throw instead of filling memory.
|
|
97
|
+
function decodeBody(buf, encoding) {
|
|
98
|
+
const opts = { maxOutputLength: ERROR_DECODED_MAX_BYTES };
|
|
99
|
+
switch ((encoding || '').trim().toLowerCase()) {
|
|
100
|
+
case 'br': return zlib.brotliDecompressSync(buf, opts);
|
|
101
|
+
case 'gzip': case 'x-gzip': return zlib.gunzipSync(buf, opts);
|
|
102
|
+
case 'deflate': return zlib.inflateSync(buf, opts);
|
|
103
|
+
default: return buf;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// "error.type: error.message" from an API error body, for the log line. The
|
|
108
|
+
// message is cut short; the full body still reaches the client.
|
|
109
|
+
function describeError(buf, encoding) {
|
|
110
|
+
try {
|
|
111
|
+
const e = JSON.parse(decodeBody(buf, encoding).toString('utf8')).error || {};
|
|
112
|
+
const msg = String(e.message || '').replace(/\s+/g, ' ').slice(0, 160);
|
|
113
|
+
return msg ? `${e.type || 'unknown_error'}: ${msg}` : (e.type || 'unknown_error');
|
|
114
|
+
} catch {
|
|
115
|
+
return 'non-JSON 429 body';
|
|
116
|
+
}
|
|
117
|
+
}
|
|
26
118
|
|
|
27
119
|
function createProxy(opts = {}) {
|
|
28
120
|
const TOKEN_DIR = opts.tokenDir || process.env.CLAUDE_ORG_TOKEN_DIR
|
|
@@ -31,12 +123,16 @@ function createProxy(opts = {}) {
|
|
|
31
123
|
|| (process.env.CLAUDE_ORG_ORDER || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
32
124
|
const UPSTREAM = new URL(opts.upstream || process.env.CLAUDE_ORG_UPSTREAM || 'https://api.anthropic.com');
|
|
33
125
|
const REJECT_FILE = 'rejectFile' in opts ? opts.rejectFile : (process.env.CLAUDE_ORG_REJECT_FILE || '');
|
|
126
|
+
const STATE_FILE = 'stateFile' in opts ? opts.stateFile : (process.env.CLAUDE_ORG_STATE_FILE || '');
|
|
127
|
+
const STRATEGY = 'strategy' in opts ? opts.strategy : (process.env.CLAUDE_ORG_STRATEGY || '');
|
|
34
128
|
const out = opts.log || process.stderr;
|
|
35
129
|
const transport = UPSTREAM.protocol === 'http:' ? http : https;
|
|
36
130
|
|
|
37
131
|
function log(...a) {
|
|
38
132
|
out.write(`[claude-org-proxy ${new Date().toISOString().slice(11, 19)}] ${a.join(' ')}\n`);
|
|
39
133
|
}
|
|
134
|
+
const leastUtilized = STRATEGY === 'least-utilized';
|
|
135
|
+
if (STRATEGY && !leastUtilized) log(`unknown CLAUDE_ORG_STRATEGY "${STRATEGY}" -> using the static order`);
|
|
40
136
|
|
|
41
137
|
function listOrgs() {
|
|
42
138
|
const files = fs.readdirSync(TOKEN_DIR).filter(f => f.endsWith('.token')).map(f => f.slice(0, -6)).sort();
|
|
@@ -59,6 +155,15 @@ function createProxy(opts = {}) {
|
|
|
59
155
|
return false;
|
|
60
156
|
}
|
|
61
157
|
}
|
|
158
|
+
// Per-(org, model) memory for model-specific refusals: "org|model" -> epoch (ms).
|
|
159
|
+
const modelRejectedUntil = new Map();
|
|
160
|
+
function isModelRejected(name, model) {
|
|
161
|
+
const key = `${name}|${model}`;
|
|
162
|
+
const until = modelRejectedUntil.get(key);
|
|
163
|
+
if (until === undefined) return false;
|
|
164
|
+
if (Date.now() >= until) { modelRejectedUntil.delete(key); return false; }
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
62
167
|
function isRejected(name) {
|
|
63
168
|
if (fileRejected(name)) return true;
|
|
64
169
|
const until = rejectedUntil.get(name);
|
|
@@ -66,11 +171,63 @@ function createProxy(opts = {}) {
|
|
|
66
171
|
if (Date.now() >= until) { rejectedUntil.delete(name); return false; }
|
|
67
172
|
return true;
|
|
68
173
|
}
|
|
174
|
+
// Latest 5h utilization (0..1) each org reported, valid until that window's
|
|
175
|
+
// reset: name -> { util, until (epoch ms) }. An org that is not picked would
|
|
176
|
+
// otherwise keep its old high value after its window resets.
|
|
177
|
+
const utilization = new Map();
|
|
178
|
+
// Orgs that answered without a utilization header, so they are not put first
|
|
179
|
+
// again and again as "not measured yet".
|
|
180
|
+
const answeredUnmeasured = new Set();
|
|
181
|
+
function utilOf(name) {
|
|
182
|
+
const e = utilization.get(name);
|
|
183
|
+
if (e === undefined) return undefined;
|
|
184
|
+
if (Date.now() >= e.until) { utilization.delete(name); return undefined; }
|
|
185
|
+
return e.util;
|
|
186
|
+
}
|
|
187
|
+
function recordUtil(name, util, reset) {
|
|
188
|
+
const until = Number.isFinite(reset) ? reset * 1000 : Infinity;
|
|
189
|
+
utilization.set(name, { util, until });
|
|
190
|
+
answeredUnmeasured.delete(name);
|
|
191
|
+
}
|
|
69
192
|
// Usable orgs first, in priority order; rate-limited orgs are moved to the
|
|
70
193
|
// tail (kept as a last resort when everything is limited).
|
|
71
|
-
|
|
194
|
+
// least-utilized: among the usable orgs, those that have not been measured
|
|
195
|
+
// come first (in priority order) so that each is measured once, then the
|
|
196
|
+
// rest by utilization, lowest first, then those that answered without a
|
|
197
|
+
// utilization. Utilization is a share of each org's own plan, not an
|
|
198
|
+
// absolute amount, so a larger plan is picked more often.
|
|
199
|
+
function pickOrder(model) {
|
|
72
200
|
const orgs = listOrgs();
|
|
73
|
-
|
|
201
|
+
const blocked = o => isRejected(o) || isModelRejected(o, model);
|
|
202
|
+
let usable = orgs.filter(o => !blocked(o));
|
|
203
|
+
if (leastUtilized) {
|
|
204
|
+
const known = usable.filter(o => utilOf(o) !== undefined).sort((a, b) => utilOf(a) - utilOf(b));
|
|
205
|
+
const unknown = usable.filter(o => utilOf(o) === undefined);
|
|
206
|
+
usable = unknown.filter(o => !answeredUnmeasured.has(o))
|
|
207
|
+
.concat(known, unknown.filter(o => answeredUnmeasured.has(o)));
|
|
208
|
+
}
|
|
209
|
+
return usable.concat(orgs.filter(blocked));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Claude Code does not show the x-claude-org response header, so the org in
|
|
213
|
+
// use is written to a file that a statusline or hook can read. Written via a
|
|
214
|
+
// rename so a reader never sees a half-written file. No prompt or token goes in.
|
|
215
|
+
// The model is stored in full (JSON escapes it), unlike the cut log copy, so
|
|
216
|
+
// two long model names stay distinguishable.
|
|
217
|
+
// On Windows the rename fails while a reader holds the file open; that write
|
|
218
|
+
// is skipped and only the first failure is logged, to keep the log readable.
|
|
219
|
+
let stateWriteFailed = false;
|
|
220
|
+
function writeState(org, model, status) {
|
|
221
|
+
if (!STATE_FILE) return;
|
|
222
|
+
const tmp = `${STATE_FILE}.${process.pid}.tmp`;
|
|
223
|
+
try {
|
|
224
|
+
fs.writeFileSync(tmp, JSON.stringify({ org, model, status, at: new Date().toISOString() }) + '\n');
|
|
225
|
+
fs.renameSync(tmp, STATE_FILE);
|
|
226
|
+
} catch (e) {
|
|
227
|
+
try { fs.unlinkSync(tmp); } catch { /* the tmp file was never created */ }
|
|
228
|
+
if (!stateWriteFailed) log(`cannot write state file ${STATE_FILE}: ${e.message} (further failures are not logged)`);
|
|
229
|
+
stateWriteFailed = true;
|
|
230
|
+
}
|
|
74
231
|
}
|
|
75
232
|
|
|
76
233
|
function forward(org, method, urlPath, headers, body) {
|
|
@@ -99,8 +256,10 @@ function createProxy(opts = {}) {
|
|
|
99
256
|
clientReq.on('end', async () => {
|
|
100
257
|
const body = Buffer.concat(chunks);
|
|
101
258
|
const isModelReq = clientReq.url.startsWith('/v1/messages');
|
|
102
|
-
const
|
|
259
|
+
const model = isModelReq ? requestModel(body) : '';
|
|
260
|
+
const order = pickOrder(model);
|
|
103
261
|
let attempt = 0;
|
|
262
|
+
let modelRefusals = 0;
|
|
104
263
|
for (const org of order) {
|
|
105
264
|
attempt++;
|
|
106
265
|
let up;
|
|
@@ -110,29 +269,86 @@ function createProxy(opts = {}) {
|
|
|
110
269
|
log(`upstream error via ${org}: ${e.message}`);
|
|
111
270
|
continue;
|
|
112
271
|
}
|
|
113
|
-
const
|
|
114
|
-
const
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
272
|
+
const hdr = name => up.headers[`anthropic-ratelimit-unified-${name}`];
|
|
273
|
+
const status5h = hdr('5h-status');
|
|
274
|
+
const status7d = hdr('7d-status');
|
|
275
|
+
const claim = hdr('representative-claim');
|
|
276
|
+
const util5h = Number(hdr('5h-utilization'));
|
|
277
|
+
if (isModelReq && hdr('5h-utilization') !== undefined && Number.isFinite(util5h)) {
|
|
278
|
+
recordUtil(org, util5h, Number(hdr('5h-reset')));
|
|
279
|
+
} else if (isModelReq) {
|
|
280
|
+
answeredUnmeasured.add(org);
|
|
281
|
+
}
|
|
282
|
+
// The 5h and 7d windows cover every model of the org, so only those make
|
|
283
|
+
// the org as a whole unusable. Other refusals are per model: a used-up
|
|
284
|
+
// per-model weekly allowance (claim=seven_day_overage_included) or a model
|
|
285
|
+
// that needs usage credits (429 with no window headers). The overall
|
|
286
|
+
// "unified-status" header is not used: it also reads "rejected" for those.
|
|
287
|
+
const orgWide = status5h === 'rejected' || status7d === 'rejected';
|
|
288
|
+
const limited = up.statusCode === 429 || orgWide;
|
|
289
|
+
const windows = `5h=${status5h ?? '?'}${claim ? `, claim=${claim}` : ''}`;
|
|
290
|
+
// A 429 body is a small JSON error; buffer it to log the reason and,
|
|
291
|
+
// if no org is left, to hand it to the client unchanged.
|
|
292
|
+
let errBody = null;
|
|
293
|
+
let reason = '';
|
|
294
|
+
if (isModelReq && up.statusCode === 429) {
|
|
295
|
+
try {
|
|
296
|
+
errBody = await readBody(up, ERROR_BODY_MAX_BYTES);
|
|
297
|
+
} catch (e) {
|
|
298
|
+
log(`upstream error via ${org} while reading 429 body: ${e.message}`);
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
reason = describeError(errBody, up.headers['content-encoding']);
|
|
302
|
+
}
|
|
303
|
+
const modelRefused = isModelReq && up.statusCode === 429 && !orgWide;
|
|
304
|
+
if (modelRefused) {
|
|
305
|
+
// retry-after is honoured when shorter (a transient per-minute limit
|
|
306
|
+
// must not keep the preferred org out for long); a long one (a weekly
|
|
307
|
+
// per-model allowance) is capped so the org is re-checked now and then.
|
|
308
|
+
const ra = retryAfterMs(up.headers['retry-after']);
|
|
309
|
+
const ms = Number.isFinite(ra) ? Math.min(ra, MODEL_REJECT_MAX_MS) : MODEL_REJECT_MAX_MS;
|
|
310
|
+
modelRejectedUntil.set(`${org}|${model}`, Date.now() + ms);
|
|
311
|
+
}
|
|
312
|
+
const isLast = attempt >= order.length;
|
|
313
|
+
if (limited && isModelReq && !isLast) {
|
|
314
|
+
if (orgWide) {
|
|
315
|
+
// When both windows are rejected the org is back only after the later reset.
|
|
316
|
+
const resets = [['5h', status5h], ['7d', status7d]]
|
|
317
|
+
.filter(([, s]) => s === 'rejected')
|
|
318
|
+
.map(([w]) => Number(hdr(`${w}-reset`)))
|
|
319
|
+
.filter(Number.isFinite);
|
|
320
|
+
rejectedUntil.set(org, resets.length ? Math.max(...resets) * 1000 : Date.now() + ORG_REJECT_FALLBACK_MS);
|
|
321
|
+
log(`${org} is rate-limited (HTTP ${up.statusCode}, ${windows}) -> retrying with next org${reason ? ` (${reason})` : ''}`);
|
|
322
|
+
} else {
|
|
323
|
+
modelRefusals++;
|
|
324
|
+
log(`${org} rejected model ${logModel(model)} (HTTP ${up.statusCode}, ${windows}, ${reason}) -> retrying with next org; ${org} stays in use for other models`);
|
|
325
|
+
}
|
|
326
|
+
if (!errBody) up.resume(); // discard the response body and try the next org
|
|
120
327
|
continue;
|
|
121
328
|
}
|
|
122
329
|
if (isModelReq) {
|
|
123
|
-
const util =
|
|
124
|
-
log(`${clientReq.method} ${clientReq.url} via ${org} -> ${up.statusCode} (5h ${status5h ?? '?'} ${util ?? '?'})`);
|
|
330
|
+
const util = hdr('5h-utilization');
|
|
331
|
+
log(`${clientReq.method} ${clientReq.url} via ${org} model=${logModel(model)} -> ${up.statusCode} (5h ${status5h ?? '?'} ${util ?? '?'})${reason ? ` ${reason}` : ''}`);
|
|
332
|
+
writeState(org, model, up.statusCode);
|
|
333
|
+
if (modelRefused && modelRefusals === attempt - 1) {
|
|
334
|
+
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`);
|
|
335
|
+
}
|
|
125
336
|
}
|
|
126
337
|
clientRes.writeHead(up.statusCode, { ...up.headers, 'x-claude-org': org });
|
|
127
|
-
|
|
338
|
+
if (errBody) {
|
|
339
|
+
clientRes.end(errBody);
|
|
340
|
+
} else {
|
|
341
|
+
up.pipe(clientRes); // SSE streaming is piped through untouched
|
|
342
|
+
}
|
|
128
343
|
return;
|
|
129
344
|
}
|
|
345
|
+
if (isModelReq) writeState(null, model, 503); // no org served it
|
|
130
346
|
clientRes.writeHead(503, { 'content-type': 'application/json' });
|
|
131
347
|
clientRes.end(JSON.stringify({ type: 'error', error: { type: 'claude_org_proxy', message: 'all orgs rate-limited or unreachable' } }));
|
|
132
348
|
});
|
|
133
349
|
});
|
|
134
350
|
|
|
135
|
-
return { server, listOrgs, log, upstream: UPSTREAM };
|
|
351
|
+
return { server, listOrgs, log, upstream: UPSTREAM, strategy: leastUtilized ? 'least-utilized' : 'order' };
|
|
136
352
|
}
|
|
137
353
|
|
|
138
354
|
// `claude-org proxy [claude args...]`: start an in-process proxy on a random
|
|
@@ -150,15 +366,17 @@ async function proxyLaunch(args) {
|
|
|
150
366
|
}
|
|
151
367
|
|
|
152
368
|
const order = process.env.CLAUDE_ORG_ORDER || resolvePrefer();
|
|
153
|
-
const
|
|
369
|
+
const runDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-org-proxy-'));
|
|
370
|
+
const logf = path.join(runDir, 'proxy.log');
|
|
371
|
+
const stateFile = path.join(runDir, 'state.json');
|
|
154
372
|
const logStream = fs.createWriteStream(logf);
|
|
155
|
-
const { server, log, upstream } = createProxy({ order: order.split(','), log: logStream });
|
|
373
|
+
const { server, log, upstream, strategy } = createProxy({ order: order.split(','), log: logStream, stateFile });
|
|
156
374
|
|
|
157
375
|
try {
|
|
158
376
|
await new Promise((resolve, reject) => {
|
|
159
377
|
server.once('error', reject);
|
|
160
378
|
server.listen(0, '127.0.0.1', () => {
|
|
161
|
-
log(`listening on http://127.0.0.1:${server.address().port} orgs=${listOrgs().join(',')} upstream=${upstream.host}`);
|
|
379
|
+
log(`listening on http://127.0.0.1:${server.address().port} orgs=${listOrgs().join(',')} upstream=${upstream.host} strategy=${strategy}`);
|
|
162
380
|
resolve();
|
|
163
381
|
});
|
|
164
382
|
});
|
|
@@ -167,7 +385,7 @@ async function proxyLaunch(args) {
|
|
|
167
385
|
return 1;
|
|
168
386
|
}
|
|
169
387
|
const port = server.address().port;
|
|
170
|
-
console.error(`-> proxy 127.0.0.1:${port} (order: ${order}, log: ${logf})`);
|
|
388
|
+
console.error(`-> proxy 127.0.0.1:${port} (order: ${order}, strategy: ${strategy}, log: ${logf})`);
|
|
171
389
|
|
|
172
390
|
// A custom ANTHROPIC_BASE_URL + AUTH_TOKEN makes Claude Code treat the session
|
|
173
391
|
// as API-key auth: prompt cache TTL drops to 5min and MCP tool search is
|
|
@@ -176,6 +394,7 @@ async function proxyLaunch(args) {
|
|
|
176
394
|
ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`,
|
|
177
395
|
ANTHROPIC_AUTH_TOKEN: 'claude-org-proxy', // dummy; the proxy replaces it
|
|
178
396
|
CLAUDE_ORG_PROXY: '1', // marker: "connector MCPs unavailable" session
|
|
397
|
+
CLAUDE_ORG_STATE_FILE: stateFile, // last org/model, for statusline and hooks
|
|
179
398
|
};
|
|
180
399
|
if (!process.env.CLAUDE_CODE_PROMPT_CACHE_TTL) envExtra.CLAUDE_CODE_PROMPT_CACHE_TTL = '1h';
|
|
181
400
|
if (!process.env.ENABLE_TOOL_SEARCH) envExtra.ENABLE_TOOL_SEARCH = 'true';
|
|
@@ -190,9 +409,9 @@ async function proxyLaunch(args) {
|
|
|
190
409
|
// `node claude-org-proxy.js`).
|
|
191
410
|
if (require.main === module) {
|
|
192
411
|
const PORT = Number(process.env.PORT || 0);
|
|
193
|
-
const { server, listOrgs, log, upstream } = createProxy();
|
|
412
|
+
const { server, listOrgs, log, upstream, strategy } = createProxy();
|
|
194
413
|
server.listen(PORT, '127.0.0.1', () => {
|
|
195
|
-
log(`listening on http://127.0.0.1:${server.address().port} orgs=${listOrgs().join(',')} upstream=${upstream.host}`);
|
|
414
|
+
log(`listening on http://127.0.0.1:${server.address().port} orgs=${listOrgs().join(',')} upstream=${upstream.host} strategy=${strategy}`);
|
|
196
415
|
});
|
|
197
416
|
}
|
|
198
417
|
|
package/lib/status.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
const {
|
|
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 =
|
|
14
|
-
shell: process.platform === 'win32',
|
|
13
|
+
const child = spawnClaude(['auth', 'status'], {
|
|
15
14
|
env: { ...process.env, ...envExtra },
|
|
16
15
|
});
|
|
17
16
|
const chunks = [];
|
|
@@ -22,7 +21,8 @@ function authStatus(envExtra = {}) {
|
|
|
22
21
|
});
|
|
23
22
|
}
|
|
24
23
|
|
|
25
|
-
|
|
24
|
+
// model: passed to probe (undefined -> CLAUDE_ORG_PROBE_MODEL or Haiku).
|
|
25
|
+
async function statusAll(model) {
|
|
26
26
|
for (const name of listOrgs()) {
|
|
27
27
|
const tok = readToken(name);
|
|
28
28
|
if (tok === null) continue;
|
|
@@ -30,7 +30,7 @@ async function statusAll() {
|
|
|
30
30
|
const out = await authStatus({ CLAUDE_CODE_OAUTH_TOKEN: tok });
|
|
31
31
|
const lines = out.split('\n').filter(l => KEYS_RE.test(l));
|
|
32
32
|
console.log(lines.length ? lines.join('\n') : ' (auth status failed)');
|
|
33
|
-
const r = await probe(name);
|
|
33
|
+
const r = await probe(name, model);
|
|
34
34
|
if (r.line) console.log(` ${r.line}`);
|
|
35
35
|
}
|
|
36
36
|
console.log('== default (no env) ==');
|
package/lib/usage.js
CHANGED
|
@@ -24,11 +24,15 @@ token org alphabetically):
|
|
|
24
24
|
claude-org auto [claude args...] launch on <prefer> if usable, else default
|
|
25
25
|
(decided once at launch)
|
|
26
26
|
claude-org <name> [claude args...] e.g. claude-org work --resume
|
|
27
|
-
claude-org probe [<name>]
|
|
28
|
-
|
|
27
|
+
claude-org probe [<name>] [--model <id>]
|
|
28
|
+
print the org's 5h window state on one line
|
|
29
|
+
(exit 0 when allowed); with --model, probe with
|
|
30
|
+
that model and add model_status= (exit 1 when
|
|
31
|
+
the org refused it)
|
|
29
32
|
claude-org watch wait until <prefer> recovers, then notify
|
|
30
33
|
(terminal + optional CLAUDE_ORG_NOTIFY_CMD)
|
|
31
|
-
claude-org status
|
|
34
|
+
claude-org status [--model <id>] show \`claude auth status\` and probe for each
|
|
35
|
+
org token
|
|
32
36
|
claude-org list list saved org names
|
|
33
37
|
|
|
34
38
|
auto / <name> only read env at launch, so they cannot switch mid-session. To keep
|
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.
|
|
3
|
+
"version": "0.3.0",
|
|
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",
|