claude-org 0.2.2 → 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 CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## Unreleased
4
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
+
5
12
  ## 0.2.2 — 2026-10-10
6
13
 
7
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)
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
@@ -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
 
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 r = await probe(rest[0] || resolvePrefer());
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.allowed ? 0 : 1;
48
+ return r.usable ? 0 : 1;
31
49
  }
32
50
  case 'pick':
33
51
  console.log(await pick());
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). The decision
7
- // reason goes to stderr as a single `-> ...` line.
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.allowed) {
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
- console.error(`-> default (${prefer} is ${r.status}, resets ${r.resetLocal})`);
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
- // probe(name): send a 1-token inference request and read the unified 5h
6
- // rate-limit headers. Returns { line, status, util, reset, resetLocal, allowed }.
7
- // `line` is the canonical one-line output:
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) return { line: null, status: 'unknown', util: '?', reset: '?', resetLocal: '?', allowed: false };
28
- const base = (process.env.CLAUDE_ORG_UPSTREAM || 'https://api.anthropic.com').replace(/\/+$/, '');
29
- let headers = null;
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({
40
- model: 'claude-haiku-4-5-20251001',
41
- max_tokens: 1,
42
- messages: [{ role: 'user', content: 'hi' }],
43
- }),
44
- signal: AbortSignal.timeout(20000),
45
- });
46
- headers = res.headers;
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
- const line = `status=${status} 5h_util=${util} 5h_reset=${reset} 5h_reset_local=${resetLocal}`;
58
- return { line, status, util, reset, resetLocal, allowed: status === 'allowed' };
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
@@ -21,9 +21,13 @@
21
21
  // "listening on" stderr line)
22
22
  // CLAUDE_ORG_TOKEN_DIR where <name>.token files live (~/.claude/org-tokens)
23
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)
24
26
  // CLAUDE_ORG_UPSTREAM forward target (default https://api.anthropic.com)
25
27
  // CLAUDE_ORG_REJECT_FILE test seam: orgs listed comma-separated in this file
26
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)
27
31
  const http = require('http');
28
32
  const https = require('https');
29
33
  const fs = require('fs');
@@ -119,12 +123,16 @@ function createProxy(opts = {}) {
119
123
  || (process.env.CLAUDE_ORG_ORDER || '').split(',').map(s => s.trim()).filter(Boolean);
120
124
  const UPSTREAM = new URL(opts.upstream || process.env.CLAUDE_ORG_UPSTREAM || 'https://api.anthropic.com');
121
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 || '');
122
128
  const out = opts.log || process.stderr;
123
129
  const transport = UPSTREAM.protocol === 'http:' ? http : https;
124
130
 
125
131
  function log(...a) {
126
132
  out.write(`[claude-org-proxy ${new Date().toISOString().slice(11, 19)}] ${a.join(' ')}\n`);
127
133
  }
134
+ const leastUtilized = STRATEGY === 'least-utilized';
135
+ if (STRATEGY && !leastUtilized) log(`unknown CLAUDE_ORG_STRATEGY "${STRATEGY}" -> using the static order`);
128
136
 
129
137
  function listOrgs() {
130
138
  const files = fs.readdirSync(TOKEN_DIR).filter(f => f.endsWith('.token')).map(f => f.slice(0, -6)).sort();
@@ -163,12 +171,63 @@ function createProxy(opts = {}) {
163
171
  if (Date.now() >= until) { rejectedUntil.delete(name); return false; }
164
172
  return true;
165
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
+ }
166
192
  // Usable orgs first, in priority order; rate-limited orgs are moved to the
167
193
  // tail (kept as a last resort when everything is limited).
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.
168
199
  function pickOrder(model) {
169
200
  const orgs = listOrgs();
170
201
  const blocked = o => isRejected(o) || isModelRejected(o, model);
171
- return orgs.filter(o => !blocked(o)).concat(orgs.filter(blocked));
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
+ }
172
231
  }
173
232
 
174
233
  function forward(org, method, urlPath, headers, body) {
@@ -214,6 +273,12 @@ function createProxy(opts = {}) {
214
273
  const status5h = hdr('5h-status');
215
274
  const status7d = hdr('7d-status');
216
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
+ }
217
282
  // The 5h and 7d windows cover every model of the org, so only those make
218
283
  // the org as a whole unusable. Other refusals are per model: a used-up
219
284
  // per-model weekly allowance (claim=seven_day_overage_included) or a model
@@ -263,7 +328,8 @@ function createProxy(opts = {}) {
263
328
  }
264
329
  if (isModelReq) {
265
330
  const util = hdr('5h-utilization');
266
- log(`${clientReq.method} ${clientReq.url} via ${org} -> ${up.statusCode} (5h ${status5h ?? '?'} ${util ?? '?'})${reason ? ` ${reason}` : ''}`);
331
+ log(`${clientReq.method} ${clientReq.url} via ${org} model=${logModel(model)} -> ${up.statusCode} (5h ${status5h ?? '?'} ${util ?? '?'})${reason ? ` ${reason}` : ''}`);
332
+ writeState(org, model, up.statusCode);
267
333
  if (modelRefused && modelRefusals === attempt - 1) {
268
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`);
269
335
  }
@@ -276,12 +342,13 @@ function createProxy(opts = {}) {
276
342
  }
277
343
  return;
278
344
  }
345
+ if (isModelReq) writeState(null, model, 503); // no org served it
279
346
  clientRes.writeHead(503, { 'content-type': 'application/json' });
280
347
  clientRes.end(JSON.stringify({ type: 'error', error: { type: 'claude_org_proxy', message: 'all orgs rate-limited or unreachable' } }));
281
348
  });
282
349
  });
283
350
 
284
- return { server, listOrgs, log, upstream: UPSTREAM };
351
+ return { server, listOrgs, log, upstream: UPSTREAM, strategy: leastUtilized ? 'least-utilized' : 'order' };
285
352
  }
286
353
 
287
354
  // `claude-org proxy [claude args...]`: start an in-process proxy on a random
@@ -299,15 +366,17 @@ async function proxyLaunch(args) {
299
366
  }
300
367
 
301
368
  const order = process.env.CLAUDE_ORG_ORDER || resolvePrefer();
302
- const logf = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'claude-org-proxy-')), 'proxy.log');
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');
303
372
  const logStream = fs.createWriteStream(logf);
304
- const { server, log, upstream } = createProxy({ order: order.split(','), log: logStream });
373
+ const { server, log, upstream, strategy } = createProxy({ order: order.split(','), log: logStream, stateFile });
305
374
 
306
375
  try {
307
376
  await new Promise((resolve, reject) => {
308
377
  server.once('error', reject);
309
378
  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}`);
379
+ log(`listening on http://127.0.0.1:${server.address().port} orgs=${listOrgs().join(',')} upstream=${upstream.host} strategy=${strategy}`);
311
380
  resolve();
312
381
  });
313
382
  });
@@ -316,7 +385,7 @@ async function proxyLaunch(args) {
316
385
  return 1;
317
386
  }
318
387
  const port = server.address().port;
319
- 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})`);
320
389
 
321
390
  // A custom ANTHROPIC_BASE_URL + AUTH_TOKEN makes Claude Code treat the session
322
391
  // as API-key auth: prompt cache TTL drops to 5min and MCP tool search is
@@ -325,6 +394,7 @@ async function proxyLaunch(args) {
325
394
  ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`,
326
395
  ANTHROPIC_AUTH_TOKEN: 'claude-org-proxy', // dummy; the proxy replaces it
327
396
  CLAUDE_ORG_PROXY: '1', // marker: "connector MCPs unavailable" session
397
+ CLAUDE_ORG_STATE_FILE: stateFile, // last org/model, for statusline and hooks
328
398
  };
329
399
  if (!process.env.CLAUDE_CODE_PROMPT_CACHE_TTL) envExtra.CLAUDE_CODE_PROMPT_CACHE_TTL = '1h';
330
400
  if (!process.env.ENABLE_TOOL_SEARCH) envExtra.ENABLE_TOOL_SEARCH = 'true';
@@ -339,9 +409,9 @@ async function proxyLaunch(args) {
339
409
  // `node claude-org-proxy.js`).
340
410
  if (require.main === module) {
341
411
  const PORT = Number(process.env.PORT || 0);
342
- const { server, listOrgs, log, upstream } = createProxy();
412
+ const { server, listOrgs, log, upstream, strategy } = createProxy();
343
413
  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}`);
414
+ log(`listening on http://127.0.0.1:${server.address().port} orgs=${listOrgs().join(',')} upstream=${upstream.host} strategy=${strategy}`);
345
415
  });
346
416
  }
347
417
 
package/lib/status.js CHANGED
@@ -21,7 +21,8 @@ function authStatus(envExtra = {}) {
21
21
  });
22
22
  }
23
23
 
24
- async function statusAll() {
24
+ // model: passed to probe (undefined -> CLAUDE_ORG_PROBE_MODEL or Haiku).
25
+ async function statusAll(model) {
25
26
  for (const name of listOrgs()) {
26
27
  const tok = readToken(name);
27
28
  if (tok === null) continue;
@@ -29,7 +30,7 @@ async function statusAll() {
29
30
  const out = await authStatus({ CLAUDE_CODE_OAUTH_TOKEN: tok });
30
31
  const lines = out.split('\n').filter(l => KEYS_RE.test(l));
31
32
  console.log(lines.length ? lines.join('\n') : ' (auth status failed)');
32
- const r = await probe(name);
33
+ const r = await probe(name, model);
33
34
  if (r.line) console.log(` ${r.line}`);
34
35
  }
35
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>] print the org's 5h window state on one line
28
- (exit 0 when allowed)
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 show \`claude auth status\` for each org token
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-org",
3
- "version": "0.2.2",
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",