claude-multiacc 1.0.17 → 1.0.19
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/bin/claude +73 -6
- package/bin/claude-accounts +110 -5
- package/package.json +1 -1
- package/tests/run-tests.sh +40 -1
package/bin/claude
CHANGED
|
@@ -999,25 +999,70 @@ fi
|
|
|
999
999
|
# the pool on its own — the shim's guess must never outlive the evidence for it.
|
|
1000
1000
|
PARK_AUTH='failed to authenticate|oauth (session|token)[a-z ]{0,20}(expired|invalid|revoked)|could not be refreshed|invalid api key|authentication_error|invalid bearer token|please run /login|run /login to'
|
|
1001
1001
|
PARK_ORG='organization has disabled|subscription access[a-z ]{0,20}disabl|disabled claude subscription|ask your admin to enable|not authorized to use claude code'
|
|
1002
|
-
|
|
1002
|
+
# A usage limit can be scoped to ONE MODEL: "You've reached your Fable 5 limit.
|
|
1003
|
+
# Switch to another model, or manage usage credits..." — the account still has
|
|
1004
|
+
# capacity for every other model, and the endpoint says so in the message itself.
|
|
1005
|
+
#
|
|
1006
|
+
# This phrasing matched NOTHING below: "reached your <model> limit" is not "limit
|
|
1007
|
+
# reached", and "usage credits" is not "credit balance". So on 2026-08-24, when
|
|
1008
|
+
# every account crossed that scoped bucket inside one hour, the shim did not even
|
|
1009
|
+
# enter its retry branch — every task died in under a second having done no work,
|
|
1010
|
+
# and the pool looked healthy the whole time.
|
|
1011
|
+
MODEL_LIMITPAT="reached your [^.]{0,40} limit|switch to another model"
|
|
1012
|
+
LIMITPAT='rate[ _-]?limit|usage limit|limit (reached|exceeded)|overloaded|"?529"?|credit balance'"|$MODEL_LIMITPAT"
|
|
1003
1013
|
ERRPAT="$LIMITPAT|$PARK_AUTH|$PARK_ORG"'|401|403|unauthorized|authentication[_ ]error|invalid[_ ](bearer|token|api key)|token (expired|revoked|invalid)|oauth.*(error|expired|invalid)'
|
|
1014
|
+
# Deliberately a full id, not an alias: the whole point of pinning --model is that
|
|
1015
|
+
# an unpinned run inherits whatever the operator was last using.
|
|
1016
|
+
FALLBACK_MODEL="${CLAUDE_MULTIACC_FALLBACK_MODEL:-claude-opus-5}"
|
|
1017
|
+
|
|
1018
|
+
# Read the --model the caller pinned (both spellings). Empty = unpinned.
|
|
1019
|
+
argv_model() {
|
|
1020
|
+
local i=0 n="${#ARGV[@]}"
|
|
1021
|
+
while [ "$i" -lt "$n" ]; do
|
|
1022
|
+
case "${ARGV[$i]}" in
|
|
1023
|
+
--model) i=$((i+1)); [ "$i" -lt "$n" ] && printf '%s\n' "${ARGV[$i]}"; return 0 ;;
|
|
1024
|
+
--model=*) printf '%s\n' "${ARGV[$i]#--model=}"; return 0 ;;
|
|
1025
|
+
esac
|
|
1026
|
+
i=$((i+1))
|
|
1027
|
+
done
|
|
1028
|
+
return 0
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
# Rewrite ARGV onto a different model, preserving the caller's spelling. An
|
|
1032
|
+
# unpinned run gets the flag appended rather than left to inherit.
|
|
1033
|
+
argv_set_model() { # $1 = model id
|
|
1034
|
+
local i=0 n="${#ARGV[@]}" found=0
|
|
1035
|
+
ARGV_FB=()
|
|
1036
|
+
while [ "$i" -lt "$n" ]; do
|
|
1037
|
+
case "${ARGV[$i]}" in
|
|
1038
|
+
--model) ARGV_FB+=("--model" "$1"); i=$((i+2)); found=1; continue ;;
|
|
1039
|
+
--model=*) ARGV_FB+=("--model=$1"); i=$((i+1)); found=1; continue ;;
|
|
1040
|
+
esac
|
|
1041
|
+
ARGV_FB+=("${ARGV[$i]}"); i=$((i+1))
|
|
1042
|
+
done
|
|
1043
|
+
[ "$found" = 0 ] && ARGV_FB+=("--model" "$1")
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1004
1046
|
PARK_SOFT_AUTH=3600 # 1h: a mis-parked healthy account is back within the hour
|
|
1005
1047
|
PARK_SOFT_ORG=21600 # 6h: an org policy will not change in minutes
|
|
1006
1048
|
|
|
1007
1049
|
attempt=1
|
|
1008
1050
|
cur="$pick"
|
|
1009
1051
|
rc=0
|
|
1052
|
+
rotated=0 # at most one account rotation, exactly as before
|
|
1053
|
+
model_fb_used=0 # ...and at most one model fallback after it
|
|
1054
|
+
ARGV=("$@")
|
|
1010
1055
|
while :; do
|
|
1011
1056
|
tok="$(acct_token "$cur")"
|
|
1012
1057
|
if [ -n "$stdin_file" ]; then exec 3< "$stdin_file"; else exec 3< /dev/null; fi
|
|
1013
1058
|
if [ -n "$tok" ]; then
|
|
1014
|
-
CLAUDE_CONFIG_DIR="$cur" CLAUDE_CODE_OAUTH_TOKEN="$tok" "$REAL" "
|
|
1059
|
+
CLAUDE_CONFIG_DIR="$cur" CLAUDE_CODE_OAUTH_TOKEN="$tok" "$REAL" "${ARGV[@]}" <&3 > "$tmpd/out" 2> "$tmpd/err"
|
|
1015
1060
|
else
|
|
1016
|
-
CLAUDE_CONFIG_DIR="$cur" "$REAL" "
|
|
1061
|
+
CLAUDE_CONFIG_DIR="$cur" "$REAL" "${ARGV[@]}" <&3 > "$tmpd/out" 2> "$tmpd/err"
|
|
1017
1062
|
fi
|
|
1018
1063
|
rc=$?
|
|
1019
1064
|
exec 3<&-
|
|
1020
|
-
if [ "$rc" -ne 0 ] && [ "$
|
|
1065
|
+
if [ "$rc" -ne 0 ] && { [ "$rotated" = 0 ] || [ "$model_fb_used" = 0 ]; } \
|
|
1021
1066
|
&& grep -qiE "$ERRPAT" "$tmpd/out" "$tmpd/err" 2>/dev/null; then
|
|
1022
1067
|
# Atomic marker writes: a reader must never observe a half-written marker
|
|
1023
1068
|
# (it would parse as garbage and, before, could be deleted as "expired").
|
|
@@ -1036,6 +1081,11 @@ while :; do
|
|
|
1036
1081
|
park_detail="run failed to authenticate"
|
|
1037
1082
|
park_soft=$((now + PARK_SOFT_AUTH))
|
|
1038
1083
|
fi
|
|
1084
|
+
# An account that ran out of ONE model has not run out. Cooling it down would
|
|
1085
|
+
# take a perfectly usable account out of the pool for every other model too —
|
|
1086
|
+
# and if the whole pool shares the bucket, cool the ENTIRE pool down at once.
|
|
1087
|
+
model_scoped=0
|
|
1088
|
+
grep -qiE "$MODEL_LIMITPAT" "$tmpd/out" "$tmpd/err" 2>/dev/null && model_scoped=1
|
|
1039
1089
|
if [ -n "$park_reason" ]; then
|
|
1040
1090
|
{
|
|
1041
1091
|
echo "$now"
|
|
@@ -1044,7 +1094,7 @@ while :; do
|
|
|
1044
1094
|
&& mv -f "$cur/.expired.$$" "$cur/.expired" 2>/dev/null \
|
|
1045
1095
|
|| rm -f "$cur/.expired.$$" 2>/dev/null || true
|
|
1046
1096
|
sel_log "$(basename "$cur") parked ($park_reason until $park_soft) — see: claude-accounts expired"
|
|
1047
|
-
|
|
1097
|
+
elif [ "$model_scoped" = 0 ]; then
|
|
1048
1098
|
{
|
|
1049
1099
|
echo $((now + 600))
|
|
1050
1100
|
echo "bucket=error-cooldown percent=? marked_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) reason=error-cooldown"
|
|
@@ -1061,15 +1111,32 @@ while :; do
|
|
|
1061
1111
|
if [ "$c" != "$cur" ]; then next="$c"; break; fi
|
|
1062
1112
|
i=$((i+1))
|
|
1063
1113
|
done
|
|
1064
|
-
if [ -n "$next" ]; then
|
|
1114
|
+
if [ -n "$next" ] && [ "$rotated" = 0 ]; then
|
|
1065
1115
|
sel_log "retry from=$(basename "$cur") to=$(basename "$next") rc=$rc"
|
|
1066
1116
|
cur="$next"
|
|
1067
1117
|
# The account that actually serves the work is the one the next run should rotate
|
|
1068
1118
|
# away from — not the one that bounced.
|
|
1069
1119
|
remember_pick "$cur"
|
|
1120
|
+
rotated=1
|
|
1070
1121
|
attempt=2
|
|
1071
1122
|
continue
|
|
1072
1123
|
fi
|
|
1124
|
+
# Another ACCOUNT could not help. If what ran out was one MODEL, the pool still
|
|
1125
|
+
# has capacity — switch to it rather than failing a task that has done no work.
|
|
1126
|
+
# Last resort by design: a healthy account must still serve the model the caller
|
|
1127
|
+
# pinned, so this only fires once rotation has already been tried and refused.
|
|
1128
|
+
if [ "$model_fb_used" = 0 ] \
|
|
1129
|
+
&& grep -qiE "$MODEL_LIMITPAT" "$tmpd/out" "$tmpd/err" 2>/dev/null; then
|
|
1130
|
+
cm="$(argv_model)"
|
|
1131
|
+
if [ "$cm" != "$FALLBACK_MODEL" ]; then
|
|
1132
|
+
argv_set_model "$FALLBACK_MODEL"
|
|
1133
|
+
ARGV=("${ARGV_FB[@]}")
|
|
1134
|
+
model_fb_used=1
|
|
1135
|
+
attempt=2
|
|
1136
|
+
sel_log "model fallback ${cm:-<unpinned>} -> $FALLBACK_MODEL on $(basename "$cur") (scoped limit)"
|
|
1137
|
+
continue
|
|
1138
|
+
fi
|
|
1139
|
+
fi
|
|
1073
1140
|
fi
|
|
1074
1141
|
break
|
|
1075
1142
|
done
|
package/bin/claude-accounts
CHANGED
|
@@ -1111,6 +1111,85 @@ cmd_relogin() {
|
|
|
1111
1111
|
[ -z "$failed" ]
|
|
1112
1112
|
}
|
|
1113
1113
|
|
|
1114
|
+
# Hand freshly fetched telemetry to every machine that cannot fetch its own.
|
|
1115
|
+
#
|
|
1116
|
+
# Only ONE machine in a pool can read the usage endpoint: it needs an OAuth grant
|
|
1117
|
+
# carrying the user:profile scope, and a setup token is minted WITHOUT it. Every
|
|
1118
|
+
# other machine fetches, is refused, and therefore knows nothing about which
|
|
1119
|
+
# accounts are drained. gas-mini ranked its whole pool BLIND for 5.7 hours and
|
|
1120
|
+
# handed two tasks to an account sitting at 100% — its newest telemetry had arrived
|
|
1121
|
+
# at 16:38 on a mutation-triggered sync, and nothing refreshed it afterwards.
|
|
1122
|
+
#
|
|
1123
|
+
# So distribution rides the REFRESH rather than the mutation: whoever can fetch
|
|
1124
|
+
# hands the answer to everyone who cannot, every pass. One rsync per target carries
|
|
1125
|
+
# every account (a few KB). Strictly best effort with hard timeouts and BatchMode —
|
|
1126
|
+
# a peer that is asleep, rebooting or off the tailnet must never fail a refresh, and
|
|
1127
|
+
# must never hang the 15-minute job long enough to collide with the next one.
|
|
1128
|
+
# The push is DETACHED. A refresh that waits on ssh is a refresh that blocks the
|
|
1129
|
+
# 15-minute job for as long as a sleeping peer takes to time out, and telemetry
|
|
1130
|
+
# that arrives late is the whole problem we are fixing — delaying the NEXT fetch to
|
|
1131
|
+
# deliver this one trades the fault for itself. It also made four time-window tests
|
|
1132
|
+
# fail, which is the same defect wearing a smaller hat: seconds spent here move
|
|
1133
|
+
# every deadline downstream.
|
|
1134
|
+
#
|
|
1135
|
+
# One at a time: the lock means a slow or unreachable target can never stack pushes
|
|
1136
|
+
# up faster than they drain.
|
|
1137
|
+
limits_distribute() {
|
|
1138
|
+
sync_is_replica && return 0
|
|
1139
|
+
command -v rsync >/dev/null 2>&1 || return 0
|
|
1140
|
+
[ "${CLAUDE_MULTIACC_NO_DISTRIBUTE:-0}" = "1" ] && return 0
|
|
1141
|
+
( limits_distribute_now >/dev/null 2>&1 & ) >/dev/null 2>&1
|
|
1142
|
+
return 0
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
limits_distribute_now() {
|
|
1146
|
+
local lock="$ACC_ROOT/tmp/limits-push.lock"
|
|
1147
|
+
mkdir -p "$ACC_ROOT/tmp" 2>/dev/null || return 0
|
|
1148
|
+
mkdir "$lock" 2>/dev/null || return 0
|
|
1149
|
+
trap 'rmdir "$lock" 2>/dev/null || true' EXIT
|
|
1150
|
+
local server sroot list id d
|
|
1151
|
+
list="$ACC_ROOT/tmp/limits-push.$$"
|
|
1152
|
+
mkdir -p "$ACC_ROOT/tmp" 2>/dev/null || return 0
|
|
1153
|
+
: > "$list" 2>/dev/null || return 0
|
|
1154
|
+
for id in $(account_ids); do
|
|
1155
|
+
d="$ACC_ROOT/$id"
|
|
1156
|
+
[ -f "$d/limits.json" ] && printf '%s/limits.json\n' "$id" >> "$list"
|
|
1157
|
+
# A marker is pushed but never un-pushed: the shim expires a cleanly-reset
|
|
1158
|
+
# .limited on its own, so a stale one costs an account some eligibility and
|
|
1159
|
+
# never grants any. Erring toward exclusion is the safe direction here.
|
|
1160
|
+
[ -f "$d/.limited" ] && printf '%s/.limited\n' "$id" >> "$list"
|
|
1161
|
+
done
|
|
1162
|
+
if [ ! -s "$list" ]; then rm -f "$list"; return 0; fi
|
|
1163
|
+
|
|
1164
|
+
server="$(sync_target)"
|
|
1165
|
+
sroot="$(sync_target_root)"
|
|
1166
|
+
if [ -n "$server" ] && ! sync_target_is_local "$server" \
|
|
1167
|
+
&& valid_ssh_target "$server" && valid_remote_path "$sroot"; then
|
|
1168
|
+
limits_push_to "$server" "$sroot" "$list"
|
|
1169
|
+
fi
|
|
1170
|
+
# Peers are the OTHER Macs — the ones actually running tasks, and so the ones
|
|
1171
|
+
# whose selection goes blind without this.
|
|
1172
|
+
manifest_peers | while IFS="$(printf '\t')" read -r pt pr pp; do
|
|
1173
|
+
[ "$pt" = "MALFORMED" ] && continue
|
|
1174
|
+
[ -n "$pt" ] && [ -n "$pr" ] || continue
|
|
1175
|
+
valid_ssh_target "$pt" || continue
|
|
1176
|
+
valid_remote_path "$pr" || continue
|
|
1177
|
+
limits_push_to "$pt" "$pr" "$list"
|
|
1178
|
+
done
|
|
1179
|
+
rm -f "$list"
|
|
1180
|
+
rmdir "$lock" 2>/dev/null || true
|
|
1181
|
+
trap - EXIT
|
|
1182
|
+
return 0
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
limits_push_to() { # $1 target, $2 remote root, $3 file list
|
|
1186
|
+
rsync -az --timeout=20 \
|
|
1187
|
+
-e 'ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new' \
|
|
1188
|
+
--files-from="$3" "$ACC_ROOT/" "$1:$2/" >>"$ACC_ROOT/sync.log" 2>&1 \
|
|
1189
|
+
|| log_to sync.log "limits push to $1 failed (telemetry there will age)"
|
|
1190
|
+
return 0
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1114
1193
|
cmd_limits() {
|
|
1115
1194
|
require_manifest
|
|
1116
1195
|
local quiet=0 force=0 json=0
|
|
@@ -1766,6 +1845,9 @@ PYEOF
|
|
|
1766
1845
|
# lock while a consumer reads its output.
|
|
1767
1846
|
limits_lock_release
|
|
1768
1847
|
trap - EXIT
|
|
1848
|
+
# Whoever just refreshed is the only machine that CAN — pass it on before the
|
|
1849
|
+
# report, so a blind peer stops picking drained accounts within one cycle.
|
|
1850
|
+
limits_distribute
|
|
1769
1851
|
# The refresher fails open per account; a NON-zero status means the pass itself
|
|
1770
1852
|
# broke (unreadable manifest, dead python). Report the state anyway — stale data
|
|
1771
1853
|
# beats silence — but hand the caller the failure, exactly as before --json existed.
|
|
@@ -2124,6 +2206,13 @@ cmd_post_sync() {
|
|
|
2124
2206
|
cmd_verify --quick
|
|
2125
2207
|
}
|
|
2126
2208
|
|
|
2209
|
+
# Version of the copy at $1, read from its package.json on disk — the only answer
|
|
2210
|
+
# that describes what will actually execute.
|
|
2211
|
+
pkg_version_at() {
|
|
2212
|
+
[ -f "$1/package.json" ] || return 0
|
|
2213
|
+
sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$1/package.json" | head -1
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2127
2216
|
cmd_self_update() {
|
|
2128
2217
|
# Update the addon in place. npm global install => npm i -g @latest (its postinstall
|
|
2129
2218
|
# re-runs install.sh). git checkout => git pull + ./install.sh. Anything else is a
|
|
@@ -2135,16 +2224,32 @@ cmd_self_update() {
|
|
|
2135
2224
|
case "$REPO_DIR" in
|
|
2136
2225
|
*/node_modules/claude-multiacc|*/node_modules/claude-multiacc/*)
|
|
2137
2226
|
command -v npm >/dev/null 2>&1 || { ulog "self-update: npm not found; skipping"; return 0; }
|
|
2138
|
-
local cur lat
|
|
2139
|
-
|
|
2227
|
+
local cur lat prefix after
|
|
2228
|
+
# Update THE COPY THAT IS RUNNING, not whichever one the ambient npm prefix
|
|
2229
|
+
# happens to point at. my-mini had two global installs — homebrew's on PATH and
|
|
2230
|
+
# nvm's under `npm root -g` — and self-update kept upgrading the nvm one and
|
|
2231
|
+
# reporting success while every `claude` invocation ran the stale homebrew copy.
|
|
2232
|
+
# It sat eleven versions behind for weeks and said "already latest" throughout.
|
|
2233
|
+
# A deploy nobody runs is not a deploy, and one that announces success is worse
|
|
2234
|
+
# than one that fails.
|
|
2235
|
+
prefix="${REPO_DIR%/lib/node_modules/claude-multiacc*}"
|
|
2236
|
+
cur="$(pkg_version_at "$REPO_DIR")"
|
|
2140
2237
|
lat="$(npm view claude-multiacc version 2>/dev/null)"
|
|
2141
2238
|
if [ -n "$lat" ] && [ "$cur" = "$lat" ]; then
|
|
2142
2239
|
ulog "self-update: already latest ($cur)"
|
|
2143
2240
|
return 0
|
|
2144
2241
|
fi
|
|
2145
|
-
ulog "self-update: npm $cur -> ${lat:-latest}"
|
|
2146
|
-
if npm install -g claude-multiacc@latest
|
|
2147
|
-
|
|
2242
|
+
ulog "self-update: npm $cur -> ${lat:-latest} (prefix $prefix)"
|
|
2243
|
+
if npm install -g --prefix "$prefix" claude-multiacc@latest \
|
|
2244
|
+
>>"$ACC_ROOT/update.log" 2>&1; then
|
|
2245
|
+
# Verify by re-reading the file on disk. npm reporting success says nothing
|
|
2246
|
+
# about which tree it wrote to.
|
|
2247
|
+
after="$(pkg_version_at "$REPO_DIR")"
|
|
2248
|
+
if [ -n "$lat" ] && [ "$after" != "$lat" ]; then
|
|
2249
|
+
ulog "self-update: npm reported success but $REPO_DIR is still $after, not $lat — this install is NOT being updated"
|
|
2250
|
+
return 1
|
|
2251
|
+
fi
|
|
2252
|
+
ulog "self-update: npm update ok ($after)"
|
|
2148
2253
|
else
|
|
2149
2254
|
ulog "self-update: npm update FAILED (see update.log)"
|
|
2150
2255
|
return 1
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-multiacc",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.19",
|
|
4
4
|
"description": "Multi-account addon for Claude Code and OpenAI Codex CLI: every claude / claude -p and every codex / codex exec runs under a randomly-picked subscription account with the most usage headroom. Mirrors to a deploy server. No API keys.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/tests/run-tests.sh
CHANGED
|
@@ -88,6 +88,15 @@ if [ -f "$ctl" ] && grep -qx "orgfail:$acct" "$ctl" 2>/dev/null; then
|
|
|
88
88
|
echo "Your organization has disabled Claude subscription access for Claude Code · Use an Anthropic API key instead, or ask your admin to enable access"
|
|
89
89
|
exit 1
|
|
90
90
|
fi
|
|
91
|
+
# A limit scoped to ONE MODEL, on EVERY account: rotating cannot help, only
|
|
92
|
+
# switching model can. Satisfied the moment the run carries the fallback model.
|
|
93
|
+
if [ -f "$ctl" ] && grep -qx "modellimit" "$ctl" 2>/dev/null; then
|
|
94
|
+
case " $* " in
|
|
95
|
+
*" claude-opus-5 "*|*"--model=claude-opus-5"*) : ;;
|
|
96
|
+
*) echo "You've reached your Fable 5 limit. Switch to another model, or manage usage credits at claude.ai/settings/usage to continue." >&2
|
|
97
|
+
exit 1 ;;
|
|
98
|
+
esac
|
|
99
|
+
fi
|
|
91
100
|
for a in "$@"; do
|
|
92
101
|
case "$a" in
|
|
93
102
|
--exit7) echo "ordinary failure, not auth related" >&2; exit 7 ;;
|
|
@@ -110,7 +119,7 @@ if [ -n "${FAKE_SESSION_ID:-}" ] && [ -n "${CLAUDE_CONFIG_DIR:-}" ]; then
|
|
|
110
119
|
sleep "${FAKE_SESSION_HOLD:-4}"
|
|
111
120
|
rm -f "$CLAUDE_CONFIG_DIR/sessions/$$.json"
|
|
112
121
|
fi
|
|
113
|
-
echo "CFG=$acct TOK=${CLAUDE_CODE_OAUTH_TOKEN:-none}"
|
|
122
|
+
echo "CFG=$acct TOK=${CLAUDE_CODE_OAUTH_TOKEN:-none} ARGS=$*"
|
|
114
123
|
EOF
|
|
115
124
|
chmod +x "$FAKEBIN/claude"
|
|
116
125
|
|
|
@@ -562,6 +571,36 @@ done
|
|
|
562
571
|
[ -f "$ACC/acct-01/.limited" ] && t_ok "failed account got error-cooldown marker" || t_fail "cooldown marker" "missing"
|
|
563
572
|
rm -f "$FAKE_CTL" "$ACC/acct-01/.limited"
|
|
564
573
|
|
|
574
|
+
# ---- 12a. every account out of ONE model: switch model, do not fail ----------
|
|
575
|
+
# 2026-08-24: all four accounts crossed the Fable weekly bucket inside an hour and
|
|
576
|
+
# every task died in under a second having done no work. Rotating accounts cannot
|
|
577
|
+
# fix a limit scoped to a model — the endpoint says so itself ("Switch to another
|
|
578
|
+
# model"). The pool still had capacity for every other model.
|
|
579
|
+
rm -f "$ACC"/acct-*/.limited
|
|
580
|
+
echo "modellimit" > "$FAKE_CTL"
|
|
581
|
+
out="$(claude -p --model claude-fable-5 hello < /dev/null 2>/dev/null)"
|
|
582
|
+
rc=$?
|
|
583
|
+
case "$out" in
|
|
584
|
+
*"ARGS="*"claude-opus-5"*) t_ok "a model-scoped limit falls back to another model" ;;
|
|
585
|
+
*) t_fail "model fallback" "rc=$rc out=$out" ;;
|
|
586
|
+
esac
|
|
587
|
+
[ "$rc" = "0" ] && t_ok "...and the task succeeds instead of failing" \
|
|
588
|
+
|| t_fail "model fallback rc" "rc=$rc"
|
|
589
|
+
grep -q "model fallback" "$ACC/selection.log" 2>/dev/null \
|
|
590
|
+
&& t_ok "the model switch is recorded in the selection log" \
|
|
591
|
+
|| t_fail "model fallback log" "nothing logged"
|
|
592
|
+
|
|
593
|
+
# The pinned model is the caller's choice and must survive a recoverable failure:
|
|
594
|
+
# fall back only once ROTATION has been tried and could not help.
|
|
595
|
+
rm -f "$ACC"/acct-*/.limited; : > "$ACC/selection.log"
|
|
596
|
+
echo "fail:acct-01" > "$FAKE_CTL"
|
|
597
|
+
out="$(claude -p --model claude-fable-5 hello < /dev/null 2>/dev/null)"
|
|
598
|
+
case "$out" in
|
|
599
|
+
*"claude-fable-5"*) t_ok "an ordinary rate limit rotates account and KEEPS the model" ;;
|
|
600
|
+
*) t_fail "model preserved" "out=$out" ;;
|
|
601
|
+
esac
|
|
602
|
+
rm -f "$FAKE_CTL" "$ACC"/acct-*/.limited
|
|
603
|
+
|
|
565
604
|
# ---- 12b. pipe stdin skips retry buffering but passes bytes through -----------
|
|
566
605
|
out="$(printf 'pipe-data' | claude -p --echo-stdin 2>/dev/null)"
|
|
567
606
|
[ "$out" = "pipe-data" ] && t_ok "pipe stdin passes through (no retry buffering)" || t_fail "pipe stdin passthrough" "got: $out"
|