@dst-justin/relay 2.3.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -1
- package/package.json +1 -1
- package/relay +637 -41
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center"><img src="assets/banner.png" alt="relay" width="600"></p>
|
|
2
2
|
|
|
3
3
|
A lightweight CLI tool for switching between multiple Claude Code accounts instantly.
|
|
4
4
|
|
|
@@ -226,6 +226,39 @@ Warmup requires the autoswitch daemon to be running (`relay autoswitch start`) t
|
|
|
226
226
|
}
|
|
227
227
|
```
|
|
228
228
|
|
|
229
|
+
## LiteLLM providers
|
|
230
|
+
|
|
231
|
+
Beyond subscription accounts, relay can route Claude Code through a [LiteLLM](https://docs.litellm.ai/) proxy — useful for supplementing subscription usage with other model providers.
|
|
232
|
+
|
|
233
|
+
```bash
|
|
234
|
+
relay provider add mylitellm --base-url http://localhost:4000 --token sk-your-litellm-key [--model claude-sonnet-4-5] [--discover-models]
|
|
235
|
+
relay provider list
|
|
236
|
+
relay provider use mylitellm # routes every future `claude` launch through it
|
|
237
|
+
relay provider off # stop routing, subscription account resumes
|
|
238
|
+
relay provider remove mylitellm
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
`--discover-models` lets Claude Code's `/model` picker show every model configured in your LiteLLM proxy's `config.yaml`, switchable live mid-session.
|
|
242
|
+
|
|
243
|
+
For a one-off session on a specific account or provider, without touching any global state:
|
|
244
|
+
|
|
245
|
+
```bash
|
|
246
|
+
relay run mylitellm -- -p "say hi" # or any claude args
|
|
247
|
+
relay run work # same as relay work, then claude
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Minimal LiteLLM `config.yaml`:
|
|
251
|
+
```yaml
|
|
252
|
+
model_list:
|
|
253
|
+
- model_name: claude-sonnet-4-5
|
|
254
|
+
litellm_params:
|
|
255
|
+
model: openai/gpt-4o
|
|
256
|
+
api_key: os.environ/OPENAI_API_KEY
|
|
257
|
+
|
|
258
|
+
general_settings:
|
|
259
|
+
master_key: sk-your-litellm-master-key
|
|
260
|
+
```
|
|
261
|
+
|
|
229
262
|
## How It Works
|
|
230
263
|
|
|
231
264
|
relay stores a snapshot of each account's OAuth credentials in `~/.claude-relay/credentials/`. Switching writes the target account's credentials back into the store that Claude Code reads from.
|
|
@@ -255,6 +288,18 @@ Sessions live in `~/.claude/projects/` and are shared across all accounts — af
|
|
|
255
288
|
|
|
256
289
|
## Changelog
|
|
257
290
|
|
|
291
|
+
### v2.4.0 — 2026-07-25
|
|
292
|
+
- Add LiteLLM provider support: `relay provider add/list/use/off/remove` routes Claude Code through a LiteLLM proxy instead of a subscription account, via `${CLAUDE_DIR}/settings.json`'s env block (never touches Keychain/credentials).
|
|
293
|
+
- Add `relay run <name>` for a one-off session pinned to a specific account or provider, independent of any global switch.
|
|
294
|
+
|
|
295
|
+
### v2.3.1 — 2026-07-12
|
|
296
|
+
- Fix: autoswitch daemon now auto-redeploys and restarts after relay itself is updated — previously the daemon file was only regenerated by `relay autoswitch start`, so an already-running daemon would silently keep running stale code (missing new features and previously-fixed bugs) until manually restarted
|
|
297
|
+
- Fix: `relay reorder` no longer drops accounts omitted from the typed order — they're now appended in their prior relative order instead of being silently removed from autoswitch rotation
|
|
298
|
+
- Fix: autoswitch reorder wizard no longer discards a typed order; supports concatenated digit shorthand (e.g. `231`)
|
|
299
|
+
- Add: standalone `relay reorder` command
|
|
300
|
+
- Fix: lock/warmup auto-default configs, autoswitch daemon auto-default order, and `render_table` all respect the persisted account order file instead of falling back to alphabetical sort
|
|
301
|
+
- Fix: account order file stays in sync on add/save/remove/rename
|
|
302
|
+
|
|
258
303
|
### v2.3.0 — 2026-07-10
|
|
259
304
|
- Add scheduled warmup: `relay warmup add/remove/list/pause/resume` to pre-warm a 5hr session at set times
|
|
260
305
|
- Daemon warmup engine (`check_warmup`/`do_warmup`) fires scheduled warmups; always pings (`claude -p ping`) on fire rather than skipping when the account looks "already active" — an idle-overnight account was silently missing its pre-warm
|
package/package.json
CHANGED
package/relay
CHANGED
|
@@ -14,8 +14,12 @@ META_STORE="${RELAY_DIR}/meta"
|
|
|
14
14
|
CURRENT_FILE="${RELAY_DIR}/current"
|
|
15
15
|
ORDER_FILE="${RELAY_DIR}/order"
|
|
16
16
|
UPDATE_CACHE="${RELAY_DIR}/.update_cache"
|
|
17
|
+
PROVIDERS_STORE="${RELAY_DIR}/providers"
|
|
18
|
+
ACTIVE_PROVIDER_FILE="${RELAY_DIR}/active_provider"
|
|
19
|
+
SETTINGS_ENV_SNAPSHOT="${RELAY_DIR}/settings_env_snapshot.json"
|
|
17
20
|
CLAUDE_DIR="${HOME}/.claude"
|
|
18
21
|
CLAUDE_JSON="${HOME}/.claude.json"
|
|
22
|
+
CLAUDE_SETTINGS="${CLAUDE_DIR}/settings.json"
|
|
19
23
|
REAL_CLAUDE=$(command -v claude 2>/dev/null || echo "")
|
|
20
24
|
# macOS: /usr/bin/python3 uses the system TLS stack (correct certs);
|
|
21
25
|
# /usr/local/bin/python3 (Homebrew/standalone) often lacks bundled certs → SSL failures
|
|
@@ -90,8 +94,8 @@ with open(lockfile, "a") as f:
|
|
|
90
94
|
return "${status}"
|
|
91
95
|
}
|
|
92
96
|
|
|
93
|
-
mkdir -p "${CREDS_STORE}" "${META_STORE}" "${CLAUDE_DIR}"
|
|
94
|
-
chmod 700 "${RELAY_DIR}" "${CREDS_STORE}" 2>/dev/null || true
|
|
97
|
+
mkdir -p "${CREDS_STORE}" "${META_STORE}" "${PROVIDERS_STORE}" "${CLAUDE_DIR}"
|
|
98
|
+
chmod 700 "${RELAY_DIR}" "${CREDS_STORE}" "${PROVIDERS_STORE}" 2>/dev/null || true
|
|
95
99
|
|
|
96
100
|
[[ -z "${PY}" ]] && { err "python3 is required"; exit 1; }
|
|
97
101
|
|
|
@@ -100,6 +104,29 @@ account_creds() { echo "${CREDS_STORE}/$1.json"; }
|
|
|
100
104
|
account_meta() { echo "${META_STORE}/$1"; }
|
|
101
105
|
account_exists() { [[ -f "$(account_creds "$1")" ]]; }
|
|
102
106
|
|
|
107
|
+
provider_file() { echo "${PROVIDERS_STORE}/$1.json"; }
|
|
108
|
+
provider_exists() { [[ -f "$(provider_file "$1")" ]]; }
|
|
109
|
+
active_provider_name() { [[ -f "${ACTIVE_PROVIDER_FILE}" ]] && cat "${ACTIVE_PROVIDER_FILE}" || echo ""; }
|
|
110
|
+
|
|
111
|
+
# list providers alphabetically, one per line (bash 3.2 compatible)
|
|
112
|
+
list_provider_names() {
|
|
113
|
+
local f
|
|
114
|
+
for f in "${PROVIDERS_STORE}"/*.json; do
|
|
115
|
+
[[ -f "${f}" ]] || continue
|
|
116
|
+
basename "${f}" .json
|
|
117
|
+
done | sort
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
# read one string field from a provider's JSON file; "" if absent
|
|
121
|
+
_provider_field() {
|
|
122
|
+
"${PY}" -c 'import json,sys; print(json.load(open(sys.argv[1])).get(sys.argv[2],"") or "")' "$(provider_file "$1")" "$2"
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
# "1" if the provider has discover_models truthy, else ""
|
|
126
|
+
_provider_discover() {
|
|
127
|
+
"${PY}" -c 'import json,sys; print("1" if json.load(open(sys.argv[1])).get("discover_models") else "")' "$(provider_file "$1")"
|
|
128
|
+
}
|
|
129
|
+
|
|
103
130
|
# list accounts in canonical add-order, one per line (bash 3.2 compatible).
|
|
104
131
|
# Self-healing: drops names whose credential file is gone, appends any
|
|
105
132
|
# credential file not yet tracked (alphabetically), and rewrites ORDER_FILE.
|
|
@@ -141,6 +168,86 @@ list_account_names() {
|
|
|
141
168
|
fi
|
|
142
169
|
}
|
|
143
170
|
|
|
171
|
+
# append name to ORDER_FILE if not already tracked (used by add/save)
|
|
172
|
+
add_to_order() {
|
|
173
|
+
local name="$1"
|
|
174
|
+
touch "${ORDER_FILE}"
|
|
175
|
+
grep -Fxq "${name}" "${ORDER_FILE}" 2>/dev/null && return 0
|
|
176
|
+
echo "${name}" >> "${ORDER_FILE}"
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
# remove name's line from ORDER_FILE (used by remove)
|
|
180
|
+
remove_from_order() {
|
|
181
|
+
local name="$1"
|
|
182
|
+
[[ -f "${ORDER_FILE}" ]] || return 0
|
|
183
|
+
local tmp="${ORDER_FILE}.tmp.$$"
|
|
184
|
+
grep -Fxv "${name}" "${ORDER_FILE}" > "${tmp}" 2>/dev/null || : > "${tmp}"
|
|
185
|
+
mv "${tmp}" "${ORDER_FILE}"
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
# rename a tracked entry in place, preserving its position (used by rename)
|
|
189
|
+
rename_in_order() {
|
|
190
|
+
local old="$1" new="$2"
|
|
191
|
+
[[ -f "${ORDER_FILE}" ]] || return 0
|
|
192
|
+
local tmp="${ORDER_FILE}.tmp.$$"
|
|
193
|
+
awk -v old="${old}" -v new="${new}" '{ print ($0 == old) ? new : $0 }' "${ORDER_FILE}" > "${tmp}"
|
|
194
|
+
mv "${tmp}" "${ORDER_FILE}"
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
# interactive numbered reorder picker. Prints the account list + prompt to
|
|
198
|
+
# stderr (so it's visible even when the caller captures stdout), reads one
|
|
199
|
+
# line from stdin, resolves it to a comma-joined order, and echoes that
|
|
200
|
+
# order to stdout for the caller to capture.
|
|
201
|
+
prompt_reorder() {
|
|
202
|
+
local accounts=("$@")
|
|
203
|
+
echo "" >&2
|
|
204
|
+
printf " ${D}When an account hits its threshold, relay switches to the next one in order.${R}\n\n" >&2
|
|
205
|
+
local i=1 acct
|
|
206
|
+
for acct in "${accounts[@]}"; do
|
|
207
|
+
printf " ${D}%d${R} %s\n" "${i}" "${acct}" >&2
|
|
208
|
+
i=$((i+1))
|
|
209
|
+
done
|
|
210
|
+
echo "" >&2
|
|
211
|
+
printf " ${D}Type numbers in the order you want (e.g. ${CY}2 1${D}, or ${CY}21${D} for <=9 accounts, or Enter to keep the order above):${R}\n" >&2
|
|
212
|
+
printf " > " >&2
|
|
213
|
+
read -r order_input
|
|
214
|
+
|
|
215
|
+
local order_str; order_str=$("${PY}" - "${order_input}" "${accounts[@]}" <<'PYEOF'
|
|
216
|
+
import sys, re
|
|
217
|
+
raw = sys.argv[1].strip()
|
|
218
|
+
accts = sys.argv[2:]
|
|
219
|
+
if not raw:
|
|
220
|
+
result = accts
|
|
221
|
+
elif raw.isdigit() and not re.search(r'[\s,]', raw) and len(accts) <= 9:
|
|
222
|
+
# concatenated shorthand, e.g. "312" -> [3, 1, 2], only unambiguous for <=9 accounts
|
|
223
|
+
result = []
|
|
224
|
+
for ch in raw:
|
|
225
|
+
idx = int(ch) - 1
|
|
226
|
+
if 0 <= idx < len(accts):
|
|
227
|
+
result.append(accts[idx])
|
|
228
|
+
else:
|
|
229
|
+
tokens = re.split(r'[\s,]+', raw)
|
|
230
|
+
result = []
|
|
231
|
+
for t in tokens:
|
|
232
|
+
t = t.strip()
|
|
233
|
+
if t.isdigit():
|
|
234
|
+
idx = int(t) - 1
|
|
235
|
+
if 0 <= idx < len(accts):
|
|
236
|
+
result.append(accts[idx])
|
|
237
|
+
elif t:
|
|
238
|
+
result.append(t)
|
|
239
|
+
print(','.join(result))
|
|
240
|
+
PYEOF
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
local chain; chain=$(echo "${order_str}" | "${PY}" -c "
|
|
244
|
+
import sys; names=sys.stdin.read().strip().split(',')
|
|
245
|
+
print(' -> '.join(names) + ' -> (cycle)')")
|
|
246
|
+
printf " ${D}Order: ${CY}%s${R}\n" "${chain}" >&2
|
|
247
|
+
|
|
248
|
+
echo "${order_str}"
|
|
249
|
+
}
|
|
250
|
+
|
|
144
251
|
account_by_index() {
|
|
145
252
|
local idx="$1" i=1 name
|
|
146
253
|
while IFS= read -r name; do
|
|
@@ -186,6 +293,145 @@ require_claude() {
|
|
|
186
293
|
exit 1
|
|
187
294
|
}
|
|
188
295
|
|
|
296
|
+
# Fully replace the 4 managed keys with exactly the given KEY=VALUE pairs.
|
|
297
|
+
# Snapshots the pre-relay original values/absences the first time this is
|
|
298
|
+
# called since the last full restore (SETTINGS_ENV_SNAPSHOT doesn't exist).
|
|
299
|
+
# Assumes the caller already holds the credential lock.
|
|
300
|
+
_settings_env_activate_locked() {
|
|
301
|
+
RELAY_SETTINGS_PATH="${CLAUDE_SETTINGS}" \
|
|
302
|
+
RELAY_SNAPSHOT_PATH="${SETTINGS_ENV_SNAPSHOT}" \
|
|
303
|
+
RELAY_ENV_PAIRS="$(printf '%s\n' "$@")" \
|
|
304
|
+
"${PY}" <<'EOF'
|
|
305
|
+
import json, os, sys, tempfile
|
|
306
|
+
|
|
307
|
+
MANAGED = ["ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_MODEL", "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"]
|
|
308
|
+
|
|
309
|
+
def load_json(p, default):
|
|
310
|
+
if not os.path.exists(p):
|
|
311
|
+
return default
|
|
312
|
+
raw = open(p).read().strip()
|
|
313
|
+
return json.loads(raw) if raw else default
|
|
314
|
+
|
|
315
|
+
def atomic_write(p, data, mode=0o600):
|
|
316
|
+
d = os.path.dirname(p) or "."
|
|
317
|
+
fd, tmp = tempfile.mkstemp(dir=d, prefix=".relay-tmp-")
|
|
318
|
+
try:
|
|
319
|
+
with os.fdopen(fd, "w") as f:
|
|
320
|
+
json.dump(data, f, indent=2)
|
|
321
|
+
f.write("\n")
|
|
322
|
+
os.chmod(tmp, mode)
|
|
323
|
+
os.replace(tmp, p)
|
|
324
|
+
except Exception:
|
|
325
|
+
try:
|
|
326
|
+
os.unlink(tmp)
|
|
327
|
+
except OSError:
|
|
328
|
+
pass
|
|
329
|
+
raise
|
|
330
|
+
|
|
331
|
+
path = os.environ["RELAY_SETTINGS_PATH"]
|
|
332
|
+
snapshot_path = os.environ["RELAY_SNAPSHOT_PATH"]
|
|
333
|
+
pairs = [p for p in os.environ.get("RELAY_ENV_PAIRS", "").split("\n") if p]
|
|
334
|
+
|
|
335
|
+
try:
|
|
336
|
+
data = load_json(path, {})
|
|
337
|
+
except json.JSONDecodeError as e:
|
|
338
|
+
print(f"settings.json is not valid JSON: {e}", file=sys.stderr)
|
|
339
|
+
sys.exit(1)
|
|
340
|
+
if not isinstance(data, dict):
|
|
341
|
+
print("settings.json root is not a JSON object", file=sys.stderr)
|
|
342
|
+
sys.exit(1)
|
|
343
|
+
|
|
344
|
+
env = data.get("env")
|
|
345
|
+
if not isinstance(env, dict):
|
|
346
|
+
env = {}
|
|
347
|
+
|
|
348
|
+
if not os.path.exists(snapshot_path):
|
|
349
|
+
snapshot = {k: env.get(k) for k in MANAGED}
|
|
350
|
+
atomic_write(snapshot_path, snapshot, 0o600)
|
|
351
|
+
|
|
352
|
+
for k in MANAGED:
|
|
353
|
+
env.pop(k, None)
|
|
354
|
+
for pair in pairs:
|
|
355
|
+
k, _, v = pair.partition("=")
|
|
356
|
+
env[k] = v
|
|
357
|
+
data["env"] = env
|
|
358
|
+
atomic_write(path, data, 0o600)
|
|
359
|
+
EOF
|
|
360
|
+
}
|
|
361
|
+
_settings_env_activate() { with_credential_lock _settings_env_activate_locked "$@"; }
|
|
362
|
+
|
|
363
|
+
# Restore the snapshotted values/absences for the 4 managed keys (or clear
|
|
364
|
+
# them if no snapshot exists, as a defensive fallback), then delete the
|
|
365
|
+
# snapshot. Assumes the caller already holds the credential lock.
|
|
366
|
+
_settings_env_restore_locked() {
|
|
367
|
+
"${PY}" - "${CLAUDE_SETTINGS}" "${SETTINGS_ENV_SNAPSHOT}" <<'EOF'
|
|
368
|
+
import json, os, sys, tempfile
|
|
369
|
+
|
|
370
|
+
MANAGED = ["ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_MODEL", "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"]
|
|
371
|
+
|
|
372
|
+
def load_json(p, default):
|
|
373
|
+
if not os.path.exists(p):
|
|
374
|
+
return default
|
|
375
|
+
raw = open(p).read().strip()
|
|
376
|
+
return json.loads(raw) if raw else default
|
|
377
|
+
|
|
378
|
+
def atomic_write(p, data, mode=0o600):
|
|
379
|
+
d = os.path.dirname(p) or "."
|
|
380
|
+
fd, tmp = tempfile.mkstemp(dir=d, prefix=".relay-tmp-")
|
|
381
|
+
try:
|
|
382
|
+
with os.fdopen(fd, "w") as f:
|
|
383
|
+
json.dump(data, f, indent=2)
|
|
384
|
+
f.write("\n")
|
|
385
|
+
os.chmod(tmp, mode)
|
|
386
|
+
os.replace(tmp, p)
|
|
387
|
+
except Exception:
|
|
388
|
+
try:
|
|
389
|
+
os.unlink(tmp)
|
|
390
|
+
except OSError:
|
|
391
|
+
pass
|
|
392
|
+
raise
|
|
393
|
+
|
|
394
|
+
path, snapshot_path = sys.argv[1], sys.argv[2]
|
|
395
|
+
|
|
396
|
+
try:
|
|
397
|
+
data = load_json(path, {})
|
|
398
|
+
except json.JSONDecodeError as e:
|
|
399
|
+
print(f"settings.json is not valid JSON: {e}", file=sys.stderr)
|
|
400
|
+
sys.exit(1)
|
|
401
|
+
if not isinstance(data, dict):
|
|
402
|
+
print("settings.json root is not a JSON object", file=sys.stderr)
|
|
403
|
+
sys.exit(1)
|
|
404
|
+
|
|
405
|
+
env = data.get("env")
|
|
406
|
+
if not isinstance(env, dict):
|
|
407
|
+
env = {}
|
|
408
|
+
|
|
409
|
+
changed = False
|
|
410
|
+
if os.path.exists(snapshot_path):
|
|
411
|
+
snapshot = load_json(snapshot_path, {})
|
|
412
|
+
for k in MANAGED:
|
|
413
|
+
v = snapshot.get(k)
|
|
414
|
+
if v is None:
|
|
415
|
+
changed = env.pop(k, None) is not None or changed
|
|
416
|
+
else:
|
|
417
|
+
changed = env.get(k) != v or changed
|
|
418
|
+
env[k] = v
|
|
419
|
+
else:
|
|
420
|
+
for k in MANAGED:
|
|
421
|
+
if k in env:
|
|
422
|
+
del env[k]
|
|
423
|
+
changed = True
|
|
424
|
+
|
|
425
|
+
if changed:
|
|
426
|
+
data["env"] = env
|
|
427
|
+
atomic_write(path, data, 0o600)
|
|
428
|
+
|
|
429
|
+
if os.path.exists(snapshot_path):
|
|
430
|
+
os.unlink(snapshot_path)
|
|
431
|
+
EOF
|
|
432
|
+
}
|
|
433
|
+
_settings_env_restore() { with_credential_lock _settings_env_restore_locked; }
|
|
434
|
+
|
|
189
435
|
# ══════════════════════════════════════════════════════════════════
|
|
190
436
|
# Python core: parallel usage fetch + table rendering
|
|
191
437
|
# args: <mode: quick|full> <creds_dir> <meta_dir> <current_name> [--no-usage]
|
|
@@ -228,7 +474,24 @@ def reset_in(iso):
|
|
|
228
474
|
except Exception:
|
|
229
475
|
return '—'
|
|
230
476
|
|
|
231
|
-
|
|
477
|
+
def read_order(creds_dir):
|
|
478
|
+
relay_dir = os.path.dirname(creds_dir)
|
|
479
|
+
order_file = os.path.join(relay_dir, 'order')
|
|
480
|
+
on_disk = set(f[:-5] for f in os.listdir(creds_dir) if f.endswith('.json')) if os.path.isdir(creds_dir) else set()
|
|
481
|
+
ordered = []
|
|
482
|
+
if os.path.exists(order_file):
|
|
483
|
+
for line in open(order_file):
|
|
484
|
+
n = line.strip()
|
|
485
|
+
if n in on_disk and n not in ordered:
|
|
486
|
+
ordered.append(n)
|
|
487
|
+
for n in sorted(on_disk):
|
|
488
|
+
if n not in ordered:
|
|
489
|
+
ordered.append(n)
|
|
490
|
+
with open(order_file, 'w') as f:
|
|
491
|
+
f.write('\n'.join(ordered) + ('\n' if ordered else ''))
|
|
492
|
+
return ordered
|
|
493
|
+
|
|
494
|
+
names = read_order(creds_dir)
|
|
232
495
|
if not names:
|
|
233
496
|
print(f' \033[33m⚠\033[0m No accounts yet. Run: {B}relay add <name>{R}')
|
|
234
497
|
sys.exit(0)
|
|
@@ -424,6 +687,11 @@ _do_switch_locked() {
|
|
|
424
687
|
local name="$1"
|
|
425
688
|
local current; current=$(current_name)
|
|
426
689
|
|
|
690
|
+
if [[ -n "$(active_provider_name)" ]]; then
|
|
691
|
+
_settings_env_restore_locked
|
|
692
|
+
rm -f "${ACTIVE_PROVIDER_FILE}"
|
|
693
|
+
fi
|
|
694
|
+
|
|
427
695
|
if [[ "${current}" == "${name}" ]]; then
|
|
428
696
|
ok "Already on account '${B}${name}${R}'"
|
|
429
697
|
return 0
|
|
@@ -463,8 +731,16 @@ _sync_current_creds() {
|
|
|
463
731
|
[[ -n "${live}" ]] && printf '%s' "${live}" > "$(account_creds "${cur}")"
|
|
464
732
|
}
|
|
465
733
|
|
|
734
|
+
_provider_banner() {
|
|
735
|
+
local active; active=$(active_provider_name)
|
|
736
|
+
[[ -z "${active}" ]] && return 0
|
|
737
|
+
local base_url; base_url=$(_provider_field "${active}" base_url)
|
|
738
|
+
printf "\n ${YL}⚡ litellm:${R} ${B}%s${R} ${D}(%s)${R}\n" "${active}" "${base_url}"
|
|
739
|
+
}
|
|
740
|
+
|
|
466
741
|
cmd_quick() {
|
|
467
742
|
_check_update_bg
|
|
743
|
+
_provider_banner
|
|
468
744
|
_sync_current_creds
|
|
469
745
|
render_table quick "${CREDS_STORE}" "${META_STORE}" "$(current_name)" "$@"
|
|
470
746
|
_show_update_notice
|
|
@@ -510,6 +786,7 @@ cmd_list() {
|
|
|
510
786
|
}
|
|
511
787
|
|
|
512
788
|
_cmd_status_once() {
|
|
789
|
+
_provider_banner
|
|
513
790
|
_sync_current_creds
|
|
514
791
|
local current; current=$(current_name)
|
|
515
792
|
hdr "Current Status"
|
|
@@ -687,12 +964,203 @@ PYEOF
|
|
|
687
964
|
fi
|
|
688
965
|
}
|
|
689
966
|
|
|
967
|
+
cmd_provider_add() {
|
|
968
|
+
local name="${1:-}"
|
|
969
|
+
[[ -z "${name}" ]] && { err "usage: relay provider add <name> --base-url <url> --token <token> [--model <model>] [--discover-models]"; exit 1; }
|
|
970
|
+
shift
|
|
971
|
+
case "${name}" in
|
|
972
|
+
*[!a-zA-Z0-9_-]*) err "name must contain only letters, numbers, underscores, or hyphens"; exit 1 ;;
|
|
973
|
+
esac
|
|
974
|
+
if account_exists "${name}"; then
|
|
975
|
+
err "'${name}' is already an account — pick a different provider name"
|
|
976
|
+
exit 1
|
|
977
|
+
fi
|
|
978
|
+
if provider_exists "${name}"; then
|
|
979
|
+
warn "Provider '${name}' already exists"
|
|
980
|
+
log "To change it: relay provider remove ${name} && relay provider add ${name} ..."
|
|
981
|
+
return 0
|
|
982
|
+
fi
|
|
983
|
+
|
|
984
|
+
local base_url="" token="" model="" discover=0
|
|
985
|
+
while [[ $# -gt 0 ]]; do
|
|
986
|
+
case "$1" in
|
|
987
|
+
--base-url) base_url="${2:-}"; shift 2 ;;
|
|
988
|
+
--token) token="${2:-}"; shift 2 ;;
|
|
989
|
+
--model) model="${2:-}"; shift 2 ;;
|
|
990
|
+
--discover-models) discover=1; shift ;;
|
|
991
|
+
*) err "unknown option: $1"; exit 1 ;;
|
|
992
|
+
esac
|
|
993
|
+
done
|
|
994
|
+
|
|
995
|
+
[[ -z "${base_url}" ]] && { err "--base-url is required"; exit 1; }
|
|
996
|
+
[[ -z "${token}" ]] && { err "--token is required"; exit 1; }
|
|
997
|
+
case "${base_url}" in
|
|
998
|
+
http://*|https://*) ;;
|
|
999
|
+
*) err "--base-url must start with http:// or https://"; exit 1 ;;
|
|
1000
|
+
esac
|
|
1001
|
+
|
|
1002
|
+
RELAY_PROVIDER_PATH="$(provider_file "${name}")" \
|
|
1003
|
+
RELAY_PROVIDER_BASE_URL="${base_url}" \
|
|
1004
|
+
RELAY_PROVIDER_TOKEN="${token}" \
|
|
1005
|
+
RELAY_PROVIDER_MODEL="${model}" \
|
|
1006
|
+
RELAY_PROVIDER_DISCOVER="${discover}" \
|
|
1007
|
+
"${PY}" -c '
|
|
1008
|
+
import json, os
|
|
1009
|
+
path = os.environ["RELAY_PROVIDER_PATH"]
|
|
1010
|
+
base_url = os.environ["RELAY_PROVIDER_BASE_URL"]
|
|
1011
|
+
token = os.environ["RELAY_PROVIDER_TOKEN"]
|
|
1012
|
+
model = os.environ.get("RELAY_PROVIDER_MODEL", "")
|
|
1013
|
+
discover = os.environ.get("RELAY_PROVIDER_DISCOVER", "")
|
|
1014
|
+
d = {"base_url": base_url, "auth_token": token}
|
|
1015
|
+
if model:
|
|
1016
|
+
d["model"] = model
|
|
1017
|
+
if discover == "1":
|
|
1018
|
+
d["discover_models"] = True
|
|
1019
|
+
with open(path, "w") as f:
|
|
1020
|
+
json.dump(d, f)
|
|
1021
|
+
os.chmod(path, 0o600)
|
|
1022
|
+
'
|
|
1023
|
+
|
|
1024
|
+
ok "Provider '${B}${name}${R}' added ${D}${base_url}${R}"
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
cmd_provider_list() {
|
|
1028
|
+
hdr "LiteLLM providers"
|
|
1029
|
+
local names; names=$(list_provider_names)
|
|
1030
|
+
if [[ -z "${names}" ]]; then
|
|
1031
|
+
warn "No providers configured — add one with: relay provider add <name> --base-url <url> --token <token>"
|
|
1032
|
+
return 0
|
|
1033
|
+
fi
|
|
1034
|
+
local active; active=$(active_provider_name)
|
|
1035
|
+
local name base_url model info
|
|
1036
|
+
while IFS= read -r name; do
|
|
1037
|
+
[[ -z "${name}" ]] && continue
|
|
1038
|
+
base_url=$(_provider_field "${name}" base_url)
|
|
1039
|
+
model=$(_provider_field "${name}" model)
|
|
1040
|
+
info="${base_url}"
|
|
1041
|
+
[[ -n "${model}" ]] && info="${info} model=${model}"
|
|
1042
|
+
if [[ "${name}" == "${active}" ]]; then
|
|
1043
|
+
printf " ${GR}${B}✓ %-16s${R} ${D}%s${R}\n" "${name}" "${info}"
|
|
1044
|
+
else
|
|
1045
|
+
printf " %-16s ${D}%s${R}\n" "${name}" "${info}"
|
|
1046
|
+
fi
|
|
1047
|
+
done <<< "${names}"
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
cmd_provider_use() {
|
|
1051
|
+
local name="${1:-}"
|
|
1052
|
+
[[ -z "${name}" ]] && { err "usage: relay provider use <name>"; exit 1; }
|
|
1053
|
+
provider_exists "${name}" || { err "Provider '${name}' not found"; exit 1; }
|
|
1054
|
+
|
|
1055
|
+
local base_url token model discover
|
|
1056
|
+
base_url=$(_provider_field "${name}" base_url)
|
|
1057
|
+
token=$(_provider_field "${name}" auth_token)
|
|
1058
|
+
model=$(_provider_field "${name}" model)
|
|
1059
|
+
discover=$(_provider_discover "${name}")
|
|
1060
|
+
|
|
1061
|
+
local pairs=("ANTHROPIC_BASE_URL=${base_url}" "ANTHROPIC_AUTH_TOKEN=${token}")
|
|
1062
|
+
[[ -n "${model}" ]] && pairs+=("ANTHROPIC_MODEL=${model}")
|
|
1063
|
+
[[ -n "${discover}" ]] && pairs+=("CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1")
|
|
1064
|
+
|
|
1065
|
+
_settings_env_activate "${pairs[@]}" || { err "Failed to update ${CLAUDE_SETTINGS}"; exit 1; }
|
|
1066
|
+
printf '%s' "${name}" > "${ACTIVE_PROVIDER_FILE}"
|
|
1067
|
+
|
|
1068
|
+
printf "\n ${GR}${B}⚡ provider active → %s${R} ${D}%s${R}\n" "${name}" "${base_url}"
|
|
1069
|
+
printf " ${D}Active sessions pick up the switch on next message. New session: ${CY}claude -c${R}\n\n"
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
cmd_provider_off() {
|
|
1073
|
+
local active; active=$(active_provider_name)
|
|
1074
|
+
if [[ -z "${active}" ]]; then
|
|
1075
|
+
warn "No provider is currently active"
|
|
1076
|
+
return 0
|
|
1077
|
+
fi
|
|
1078
|
+
_settings_env_restore || { err "Failed to update ${CLAUDE_SETTINGS}"; exit 1; }
|
|
1079
|
+
rm -f "${ACTIVE_PROVIDER_FILE}"
|
|
1080
|
+
ok "Provider mode off — subscription account resumes"
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
cmd_provider_remove() {
|
|
1084
|
+
local name="${1:-}"
|
|
1085
|
+
[[ -z "${name}" ]] && { err "usage: relay provider remove <name>"; exit 1; }
|
|
1086
|
+
provider_exists "${name}" || { err "Provider '${name}' not found"; exit 1; }
|
|
1087
|
+
printf "\n ${YL}Delete provider '${B}${name}${R}${YL}'? (y/N) ${R}"
|
|
1088
|
+
read -r c
|
|
1089
|
+
[[ "${c}" = "y" || "${c}" = "Y" ]] || { log "cancelled"; return 0; }
|
|
1090
|
+
if [[ "$(active_provider_name)" == "${name}" ]]; then
|
|
1091
|
+
cmd_provider_off
|
|
1092
|
+
fi
|
|
1093
|
+
rm -f "$(provider_file "${name}")"
|
|
1094
|
+
ok "Deleted provider '${name}'"
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
cmd_run() {
|
|
1098
|
+
local name="${1:-}"
|
|
1099
|
+
[[ -z "${name}" ]] && { err "usage: relay run <name> [-- <claude args...>]"; exit 1; }
|
|
1100
|
+
shift
|
|
1101
|
+
[[ "${1:-}" == "--" ]] && shift
|
|
1102
|
+
|
|
1103
|
+
require_claude
|
|
1104
|
+
|
|
1105
|
+
if account_exists "${name}"; then
|
|
1106
|
+
do_switch "${name}"
|
|
1107
|
+
exec "${REAL_CLAUDE}" "$@"
|
|
1108
|
+
elif provider_exists "${name}"; then
|
|
1109
|
+
local base_url token model discover settings_file
|
|
1110
|
+
base_url=$(_provider_field "${name}" base_url)
|
|
1111
|
+
token=$(_provider_field "${name}" auth_token)
|
|
1112
|
+
model=$(_provider_field "${name}" model)
|
|
1113
|
+
discover=$(_provider_discover "${name}")
|
|
1114
|
+
|
|
1115
|
+
# Opportunistic cleanup: a prior `relay run <provider>` can't clean up
|
|
1116
|
+
# after itself (exec replaces the process, so no trap ever fires) —
|
|
1117
|
+
# sweep anything old enough that the session which created it has
|
|
1118
|
+
# almost certainly ended, so these don't accumulate indefinitely.
|
|
1119
|
+
find "${RELAY_DIR}" -maxdepth 1 -name '.run-settings.*' -mtime +1 -delete 2>/dev/null || true
|
|
1120
|
+
|
|
1121
|
+
# Written to a chmod-600 file under RELAY_DIR (mktemp's default file
|
|
1122
|
+
# mode is already 0600) rather than passed inline via `claude`'s own
|
|
1123
|
+
# argv — the exec'd claude process's cmdline would otherwise expose
|
|
1124
|
+
# this provider's auth token to any local user via `ps`/`ps aux` for
|
|
1125
|
+
# the entire session lifetime.
|
|
1126
|
+
settings_file=$(mktemp "${RELAY_DIR}/.run-settings.XXXXXX")
|
|
1127
|
+
RELAY_RUN_BASE_URL="${base_url}" \
|
|
1128
|
+
RELAY_RUN_TOKEN="${token}" \
|
|
1129
|
+
RELAY_RUN_MODEL="${model}" \
|
|
1130
|
+
RELAY_RUN_DISCOVER="${discover}" \
|
|
1131
|
+
RELAY_RUN_SETTINGS_FILE="${settings_file}" \
|
|
1132
|
+
"${PY}" -c '
|
|
1133
|
+
import json, os
|
|
1134
|
+
base_url = os.environ["RELAY_RUN_BASE_URL"]
|
|
1135
|
+
token = os.environ["RELAY_RUN_TOKEN"]
|
|
1136
|
+
model = os.environ.get("RELAY_RUN_MODEL", "")
|
|
1137
|
+
discover = os.environ.get("RELAY_RUN_DISCOVER", "")
|
|
1138
|
+
env = {"ANTHROPIC_BASE_URL": base_url, "ANTHROPIC_AUTH_TOKEN": token}
|
|
1139
|
+
if model:
|
|
1140
|
+
env["ANTHROPIC_MODEL"] = model
|
|
1141
|
+
if discover == "1":
|
|
1142
|
+
env["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] = "1"
|
|
1143
|
+
with open(os.environ["RELAY_RUN_SETTINGS_FILE"], "w") as f:
|
|
1144
|
+
json.dump({"env": env}, f)
|
|
1145
|
+
'
|
|
1146
|
+
|
|
1147
|
+
exec "${REAL_CLAUDE}" --settings "${settings_file}" "$@"
|
|
1148
|
+
else
|
|
1149
|
+
err "Unknown account or provider: ${name}"
|
|
1150
|
+
exit 1
|
|
1151
|
+
fi
|
|
1152
|
+
}
|
|
1153
|
+
|
|
690
1154
|
cmd_add() {
|
|
691
1155
|
local name="${1:-}"
|
|
692
1156
|
[[ -z "${name}" ]] && { err "usage: relay add <name>"; exit 1; }
|
|
693
1157
|
case "${name}" in
|
|
694
1158
|
*[!a-zA-Z0-9_-]*) err "name must contain only letters, numbers, underscores, or hyphens"; exit 1 ;;
|
|
695
1159
|
esac
|
|
1160
|
+
if provider_exists "${name}"; then
|
|
1161
|
+
err "'${name}' is already a LiteLLM provider — pick a different account name"
|
|
1162
|
+
exit 1
|
|
1163
|
+
fi
|
|
696
1164
|
require_claude
|
|
697
1165
|
if account_exists "${name}"; then
|
|
698
1166
|
warn "Account '${name}' already exists"
|
|
@@ -731,6 +1199,7 @@ cmd_add() {
|
|
|
731
1199
|
printf '%s' "${kc_creds}" > "$(account_creds "${name}")"
|
|
732
1200
|
chmod 600 "$(account_creds "${name}")"
|
|
733
1201
|
save_meta_email "${name}"
|
|
1202
|
+
add_to_order "${name}"
|
|
734
1203
|
echo "${name}" > "${CURRENT_FILE}"
|
|
735
1204
|
ok "Account '${B}${name}${R}' added ${D}$(get_meta_email "${name}")${R}"
|
|
736
1205
|
}
|
|
@@ -753,6 +1222,7 @@ cmd_save() {
|
|
|
753
1222
|
[[ ${saved} -eq 0 ]] && { err "No credentials found — log in first with: claude /login"; exit 1; }
|
|
754
1223
|
|
|
755
1224
|
save_meta_email "${name}"
|
|
1225
|
+
add_to_order "${name}"
|
|
756
1226
|
echo "${name}" > "${CURRENT_FILE}"
|
|
757
1227
|
ok "Account '${B}${name}${R}' saved ${D}$(get_meta_email "${name}")${R}"
|
|
758
1228
|
}
|
|
@@ -851,6 +1321,7 @@ cmd_remove() {
|
|
|
851
1321
|
read -r c
|
|
852
1322
|
[[ "${c}" = "y" || "${c}" = "Y" ]] || { log "cancelled"; return 0; }
|
|
853
1323
|
rm -f "$(account_creds "${name}")" "$(account_meta "${name}")"
|
|
1324
|
+
remove_from_order "${name}"
|
|
854
1325
|
[[ "$(current_name)" == "${name}" ]] && rm -f "${CURRENT_FILE}"
|
|
855
1326
|
ok "Deleted '${name}' (sessions are unaffected)"
|
|
856
1327
|
}
|
|
@@ -933,6 +1404,7 @@ cmd_rename() {
|
|
|
933
1404
|
|
|
934
1405
|
mv "$(account_creds "${old}")" "$(account_creds "${new}")"
|
|
935
1406
|
[[ -f "$(account_meta "${old}")" ]] && mv "$(account_meta "${old}")" "$(account_meta "${new}")"
|
|
1407
|
+
rename_in_order "${old}" "${new}"
|
|
936
1408
|
[[ "$(current_name)" == "${old}" ]] && echo "${new}" > "${CURRENT_FILE}"
|
|
937
1409
|
ok "Renamed '${B}${old}${R}' → '${B}${new}${R}'"
|
|
938
1410
|
}
|
|
@@ -1239,6 +1711,22 @@ def load_raw_config():
|
|
|
1239
1711
|
log_event('config_parse_error', error=str(e))
|
|
1240
1712
|
return {}
|
|
1241
1713
|
|
|
1714
|
+
def _read_order(creds_dir):
|
|
1715
|
+
order_file = os.path.join(os.path.dirname(creds_dir), 'order')
|
|
1716
|
+
on_disk = set(f[:-5] for f in os.listdir(creds_dir) if f.endswith('.json')) if os.path.isdir(creds_dir) else set()
|
|
1717
|
+
ordered = []
|
|
1718
|
+
if os.path.exists(order_file):
|
|
1719
|
+
for line in open(order_file):
|
|
1720
|
+
n = line.strip()
|
|
1721
|
+
if n in on_disk and n not in ordered:
|
|
1722
|
+
ordered.append(n)
|
|
1723
|
+
for n in sorted(on_disk):
|
|
1724
|
+
if n not in ordered:
|
|
1725
|
+
ordered.append(n)
|
|
1726
|
+
with open(order_file, 'w') as f:
|
|
1727
|
+
f.write('\n'.join(ordered) + ('\n' if ordered else ''))
|
|
1728
|
+
return ordered
|
|
1729
|
+
|
|
1242
1730
|
def load_config():
|
|
1243
1731
|
try:
|
|
1244
1732
|
return json.load(open(CONFIG_FILE))
|
|
@@ -1246,7 +1734,7 @@ def load_config():
|
|
|
1246
1734
|
# Auto-default: 2+ accounts → enable with 80% threshold, no explicit config needed
|
|
1247
1735
|
if not os.path.isdir(CREDS_DIR):
|
|
1248
1736
|
return None
|
|
1249
|
-
accounts =
|
|
1737
|
+
accounts = _read_order(CREDS_DIR)
|
|
1250
1738
|
if len(accounts) < 2:
|
|
1251
1739
|
return None
|
|
1252
1740
|
return {
|
|
@@ -1335,6 +1823,44 @@ if __name__ == '__main__':
|
|
|
1335
1823
|
main()
|
|
1336
1824
|
DAEMON_EOF
|
|
1337
1825
|
chmod 755 "${AUTOSWITCH_DAEMON}"
|
|
1826
|
+
_read_version > "${RELAY_DIR}/daemon_version"
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
# Restarts the already-installed daemon service in place (launchd/systemd/cron),
|
|
1830
|
+
# without touching plist/service file contents. Used after a silent redeploy.
|
|
1831
|
+
_restart_daemon_service() {
|
|
1832
|
+
if [[ "$(uname)" == "Darwin" ]] && [[ -f "${AUTOSWITCH_PLIST}" ]]; then
|
|
1833
|
+
launchctl unload "${AUTOSWITCH_PLIST}" 2>/dev/null || true
|
|
1834
|
+
launchctl load "${AUTOSWITCH_PLIST}"
|
|
1835
|
+
elif command -v systemctl >/dev/null 2>&1 && [[ -f "${AUTOSWITCH_SERVICE}" ]]; then
|
|
1836
|
+
systemctl --user restart relay-autoswitch
|
|
1837
|
+
else
|
|
1838
|
+
# cron fallback: no long-lived unit to restart — kill the running instance so
|
|
1839
|
+
# the next cron tick (daemon's own lock file) starts a fresh copy of the file
|
|
1840
|
+
# we just wrote via _extract_daemon.
|
|
1841
|
+
local pid; pid=$(cat "${RELAY_DIR}/autoswitch.lock" 2>/dev/null || echo "")
|
|
1842
|
+
[[ -n "${pid}" ]] && kill "${pid}" 2>/dev/null || true
|
|
1843
|
+
fi
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
# Runs on every invocation (cheap no-op unless autoswitch is actually running):
|
|
1847
|
+
# if relay itself was updated (npm/git/direct) since the daemon file on disk was
|
|
1848
|
+
# generated, silently regenerate it from the current script and restart it —
|
|
1849
|
+
# so daemon-side fixes (warmup engine, credential lock, etc.) don't require the
|
|
1850
|
+
# user to remember to run `relay autoswitch start` again after every update.
|
|
1851
|
+
_maybe_redeploy_daemon() {
|
|
1852
|
+
[[ -f "${AUTOSWITCH_DAEMON}" ]] || return 0
|
|
1853
|
+
local pid; pid=$(cat "${RELAY_DIR}/autoswitch.lock" 2>/dev/null || echo "")
|
|
1854
|
+
[[ -n "${pid}" ]] && kill -0 "${pid}" 2>/dev/null || return 0
|
|
1855
|
+
|
|
1856
|
+
local deployed; deployed=$(cat "${RELAY_DIR}/daemon_version" 2>/dev/null || echo "")
|
|
1857
|
+
local current; current=$(_read_version)
|
|
1858
|
+
[[ "${current}" == "unknown" ]] && return 0
|
|
1859
|
+
[[ "${deployed}" == "${current}" ]] && return 0
|
|
1860
|
+
|
|
1861
|
+
_extract_daemon
|
|
1862
|
+
_restart_daemon_service
|
|
1863
|
+
warn "relay updated to ${current} — autoswitch daemon redeployed and restarted"
|
|
1338
1864
|
}
|
|
1339
1865
|
|
|
1340
1866
|
cmd_autoswitch_config() {
|
|
@@ -1352,41 +1878,7 @@ cmd_autoswitch_config() {
|
|
|
1352
1878
|
# ── Step 1: switch order ───────────────────────────────────────
|
|
1353
1879
|
echo ""
|
|
1354
1880
|
printf " ${B}Step 1 / 3 — Switch order${R}\n"
|
|
1355
|
-
|
|
1356
|
-
local i=1
|
|
1357
|
-
for acct in "${accounts[@]}"; do
|
|
1358
|
-
printf " ${D}%d${R} %s\n" "${i}" "${acct}"
|
|
1359
|
-
i=$((i+1))
|
|
1360
|
-
done
|
|
1361
|
-
echo ""
|
|
1362
|
-
printf " ${D}Type numbers in the order you want (e.g. ${CY}2 1${D} or just Enter to keep the order above):${R}\n"
|
|
1363
|
-
printf " > "; read -r order_input
|
|
1364
|
-
|
|
1365
|
-
# resolve numbers (space or comma) to names; empty = default order
|
|
1366
|
-
local order_str; order_str=$(echo "${order_input}" | "${PY}" - "${accounts[@]}" <<'PYEOF'
|
|
1367
|
-
import sys, re
|
|
1368
|
-
raw = sys.stdin.read().strip()
|
|
1369
|
-
accts = sys.argv[1:]
|
|
1370
|
-
if not raw:
|
|
1371
|
-
print(','.join(accts))
|
|
1372
|
-
else:
|
|
1373
|
-
tokens = re.split(r'[\s,]+', raw)
|
|
1374
|
-
result = []
|
|
1375
|
-
for t in tokens:
|
|
1376
|
-
t = t.strip()
|
|
1377
|
-
if t.isdigit():
|
|
1378
|
-
idx = int(t) - 1
|
|
1379
|
-
if 0 <= idx < len(accts): result.append(accts[idx])
|
|
1380
|
-
elif t:
|
|
1381
|
-
result.append(t)
|
|
1382
|
-
print(','.join(result))
|
|
1383
|
-
PYEOF
|
|
1384
|
-
)
|
|
1385
|
-
# show resolved order as a visual chain
|
|
1386
|
-
local chain; chain=$(echo "${order_str}" | "${PY}" -c "
|
|
1387
|
-
import sys; names=sys.stdin.read().strip().split(',')
|
|
1388
|
-
print(' → '.join(names) + ' → (cycle)')")
|
|
1389
|
-
printf " ${D}Order: ${CY}%s${R}\n" "${chain}"
|
|
1881
|
+
local order_str; order_str=$(prompt_reorder "${accounts[@]}")
|
|
1390
1882
|
|
|
1391
1883
|
# ── Step 2: thresholds ────────────────────────────────────────
|
|
1392
1884
|
echo ""
|
|
@@ -1684,6 +2176,62 @@ cmd_autoswitch() {
|
|
|
1684
2176
|
esac
|
|
1685
2177
|
}
|
|
1686
2178
|
|
|
2179
|
+
cmd_reorder() {
|
|
2180
|
+
hdr "reorder accounts"
|
|
2181
|
+
|
|
2182
|
+
local accounts=()
|
|
2183
|
+
local name
|
|
2184
|
+
while IFS= read -r name; do accounts+=("${name}"); done < <(list_account_names)
|
|
2185
|
+
|
|
2186
|
+
if [[ ${#accounts[@]} -eq 0 ]]; then
|
|
2187
|
+
err "No accounts found. Run: relay add <name>"
|
|
2188
|
+
exit 1
|
|
2189
|
+
fi
|
|
2190
|
+
|
|
2191
|
+
local order_str; order_str=$(prompt_reorder "${accounts[@]}")
|
|
2192
|
+
|
|
2193
|
+
if [[ -z "${order_str}" ]]; then
|
|
2194
|
+
err "No valid order given — nothing changed"
|
|
2195
|
+
exit 1
|
|
2196
|
+
fi
|
|
2197
|
+
|
|
2198
|
+
local order_arr=()
|
|
2199
|
+
IFS=',' read -ra order_arr <<< "${order_str}"
|
|
2200
|
+
|
|
2201
|
+
local account ordered found
|
|
2202
|
+
for account in ${accounts[@]+"${accounts[@]}"}; do
|
|
2203
|
+
found=0
|
|
2204
|
+
for ordered in ${order_arr[@]+"${order_arr[@]}"}; do
|
|
2205
|
+
[[ "${ordered}" == "${account}" ]] && { found=1; break; }
|
|
2206
|
+
done
|
|
2207
|
+
[[ "${found}" -eq 0 ]] && order_arr+=("${account}")
|
|
2208
|
+
done
|
|
2209
|
+
|
|
2210
|
+
order_str=""
|
|
2211
|
+
for ordered in ${order_arr[@]+"${order_arr[@]}"}; do
|
|
2212
|
+
if [[ -z "${order_str}" ]]; then
|
|
2213
|
+
order_str="${ordered}"
|
|
2214
|
+
else
|
|
2215
|
+
order_str="${order_str},${ordered}"
|
|
2216
|
+
fi
|
|
2217
|
+
done
|
|
2218
|
+
|
|
2219
|
+
printf '%s\n' "${order_arr[@]}" > "${ORDER_FILE}"
|
|
2220
|
+
ok "Order saved to ${ORDER_FILE}"
|
|
2221
|
+
|
|
2222
|
+
local cfg="${RELAY_DIR}/autoswitch.json"
|
|
2223
|
+
if [[ -f "${cfg}" ]]; then
|
|
2224
|
+
"${PY}" - "${cfg}" "${order_str}" <<'PYEOF'
|
|
2225
|
+
import json, sys
|
|
2226
|
+
cfg_path, order_str = sys.argv[1], sys.argv[2]
|
|
2227
|
+
cfg = json.load(open(cfg_path))
|
|
2228
|
+
cfg['order'] = order_str.split(',')
|
|
2229
|
+
json.dump(cfg, open(cfg_path, 'w'), indent=2)
|
|
2230
|
+
PYEOF
|
|
2231
|
+
ok "Also updated order in ${cfg}"
|
|
2232
|
+
fi
|
|
2233
|
+
}
|
|
2234
|
+
|
|
1687
2235
|
cmd_lock() {
|
|
1688
2236
|
local name="${1:-}"
|
|
1689
2237
|
local cfg="${RELAY_DIR}/autoswitch.json"
|
|
@@ -1717,7 +2265,19 @@ PYEOF
|
|
|
1717
2265
|
"${PY}" - "${CREDS_STORE}" "${cfg}" <<'PYEOF'
|
|
1718
2266
|
import json, sys, os
|
|
1719
2267
|
creds_dir, cfg_path = sys.argv[1], sys.argv[2]
|
|
1720
|
-
|
|
2268
|
+
order_file = os.path.join(os.path.dirname(creds_dir), 'order')
|
|
2269
|
+
on_disk = set(f[:-5] for f in os.listdir(creds_dir) if f.endswith('.json'))
|
|
2270
|
+
accounts = []
|
|
2271
|
+
if os.path.exists(order_file):
|
|
2272
|
+
for line in open(order_file):
|
|
2273
|
+
n = line.strip()
|
|
2274
|
+
if n in on_disk and n not in accounts:
|
|
2275
|
+
accounts.append(n)
|
|
2276
|
+
for n in sorted(on_disk):
|
|
2277
|
+
if n not in accounts:
|
|
2278
|
+
accounts.append(n)
|
|
2279
|
+
with open(order_file, 'w') as f:
|
|
2280
|
+
f.write('\n'.join(accounts) + ('\n' if accounts else ''))
|
|
1721
2281
|
config = {
|
|
1722
2282
|
'order': accounts,
|
|
1723
2283
|
'thresholds': {a: 80 for a in accounts},
|
|
@@ -1784,7 +2344,19 @@ _warmup_ensure_config() {
|
|
|
1784
2344
|
"${PY}" - "${CREDS_STORE}" "${cfg}" <<'PYEOF'
|
|
1785
2345
|
import json, sys, os
|
|
1786
2346
|
creds_dir, cfg_path = sys.argv[1], sys.argv[2]
|
|
1787
|
-
|
|
2347
|
+
on_disk = set(f[:-5] for f in os.listdir(creds_dir) if f.endswith('.json')) if os.path.isdir(creds_dir) else set()
|
|
2348
|
+
order_file = os.path.join(os.path.dirname(creds_dir), 'order')
|
|
2349
|
+
accounts = []
|
|
2350
|
+
if os.path.exists(order_file):
|
|
2351
|
+
for line in open(order_file):
|
|
2352
|
+
n = line.strip()
|
|
2353
|
+
if n in on_disk and n not in accounts:
|
|
2354
|
+
accounts.append(n)
|
|
2355
|
+
for n in sorted(on_disk):
|
|
2356
|
+
if n not in accounts:
|
|
2357
|
+
accounts.append(n)
|
|
2358
|
+
with open(order_file, 'w') as f:
|
|
2359
|
+
f.write('\n'.join(accounts) + ('\n' if accounts else ''))
|
|
1788
2360
|
config = {
|
|
1789
2361
|
'order': accounts,
|
|
1790
2362
|
'thresholds': {a: 80 for a in accounts},
|
|
@@ -2164,6 +2736,8 @@ cmd_help() {
|
|
|
2164
2736
|
printf " %-32s %s\n" " relay refresh-all" "silent OAuth refresh for all accounts"
|
|
2165
2737
|
printf " %-32s %s\n" " relay save <name>" "save current login state"
|
|
2166
2738
|
printf " %-32s %s\n" " relay rename <old> <new>" "rename an account"
|
|
2739
|
+
printf " %-32s %s\n" " relay reorder" "change account display/switch order"
|
|
2740
|
+
printf " %-32s %s\n" " relay run <name>" "one-off session on an account or provider"
|
|
2167
2741
|
printf " %-32s %s\n" " relay list" "full list with weekly usage"
|
|
2168
2742
|
printf " %-32s %s\n" " relay list -f" "live-refresh mode (Ctrl+C to exit)"
|
|
2169
2743
|
printf " %-32s %s\n" " relay list --no-usage" "list without querying API"
|
|
@@ -2179,6 +2753,13 @@ cmd_help() {
|
|
|
2179
2753
|
printf " %-32s %s\n" " relay autoswitch start/stop" "manage background daemon"
|
|
2180
2754
|
printf " %-32s %s\n" " relay autoswitch status" "daemon state + thresholds"
|
|
2181
2755
|
echo ""
|
|
2756
|
+
printf " ${B}LiteLLM providers${R}\n"
|
|
2757
|
+
printf " %-32s %s\n" " relay provider add <name> --base-url <url> --token <token>" "add a provider"
|
|
2758
|
+
printf " %-32s %s\n" " relay provider list" "list configured providers"
|
|
2759
|
+
printf " %-32s %s\n" " relay provider use <name>" "route all future sessions through this provider"
|
|
2760
|
+
printf " %-32s %s\n" " relay provider off" "stop routing through a provider, resume subscription"
|
|
2761
|
+
printf " %-32s %s\n" " relay provider remove <name>" "delete a provider"
|
|
2762
|
+
echo ""
|
|
2182
2763
|
printf " ${B}Warmup${R}\n"
|
|
2183
2764
|
printf " %-32s %s\n" " relay warmup add <acct> <HH:MM>" "pre-warm an account's 5hr window daily"
|
|
2184
2765
|
printf " %-32s %s\n" " relay warmup remove <acct> [HH:MM]" "remove a warmup schedule"
|
|
@@ -2194,6 +2775,8 @@ cmd_help() {
|
|
|
2194
2775
|
_show_update_notice
|
|
2195
2776
|
}
|
|
2196
2777
|
|
|
2778
|
+
_maybe_redeploy_daemon
|
|
2779
|
+
|
|
2197
2780
|
# ══════════════════════════════════════════════════════════════════
|
|
2198
2781
|
# Dispatch — single entry point, no fall-through
|
|
2199
2782
|
# ══════════════════════════════════════════════════════════════════
|
|
@@ -2222,6 +2805,19 @@ case "${CMD}" in
|
|
|
2222
2805
|
status|st) cmd_status ;;
|
|
2223
2806
|
remove|rm|del) cmd_remove "$@" ;;
|
|
2224
2807
|
rename|mv) cmd_rename "$@" ;;
|
|
2808
|
+
reorder) cmd_reorder "$@" ;;
|
|
2809
|
+
provider|prov)
|
|
2810
|
+
sub="${1:-}"
|
|
2811
|
+
[[ -n "${1:-}" ]] && shift
|
|
2812
|
+
case "${sub}" in
|
|
2813
|
+
add) cmd_provider_add "$@" ;;
|
|
2814
|
+
list|ls) cmd_provider_list ;;
|
|
2815
|
+
use) cmd_provider_use "$@" ;;
|
|
2816
|
+
off) cmd_provider_off ;;
|
|
2817
|
+
remove|rm) cmd_provider_remove "$@" ;;
|
|
2818
|
+
*) err "usage: relay provider <add|list|use|off|remove> ..."; exit 1 ;;
|
|
2819
|
+
esac ;;
|
|
2820
|
+
run) cmd_run "$@" ;;
|
|
2225
2821
|
sessions|sess) cmd_sessions ;;
|
|
2226
2822
|
autoswitch|as) cmd_autoswitch "$@" ;;
|
|
2227
2823
|
lock) cmd_lock "$@" ;;
|