@dst-justin/relay 2.3.1 → 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 +38 -1
- package/package.json +1 -1
- package/relay +393 -2
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,10 @@ 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
|
+
|
|
258
295
|
### v2.3.1 — 2026-07-12
|
|
259
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
|
|
260
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
|
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.
|
|
@@ -266,6 +293,145 @@ require_claude() {
|
|
|
266
293
|
exit 1
|
|
267
294
|
}
|
|
268
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
|
+
|
|
269
435
|
# ══════════════════════════════════════════════════════════════════
|
|
270
436
|
# Python core: parallel usage fetch + table rendering
|
|
271
437
|
# args: <mode: quick|full> <creds_dir> <meta_dir> <current_name> [--no-usage]
|
|
@@ -521,6 +687,11 @@ _do_switch_locked() {
|
|
|
521
687
|
local name="$1"
|
|
522
688
|
local current; current=$(current_name)
|
|
523
689
|
|
|
690
|
+
if [[ -n "$(active_provider_name)" ]]; then
|
|
691
|
+
_settings_env_restore_locked
|
|
692
|
+
rm -f "${ACTIVE_PROVIDER_FILE}"
|
|
693
|
+
fi
|
|
694
|
+
|
|
524
695
|
if [[ "${current}" == "${name}" ]]; then
|
|
525
696
|
ok "Already on account '${B}${name}${R}'"
|
|
526
697
|
return 0
|
|
@@ -560,8 +731,16 @@ _sync_current_creds() {
|
|
|
560
731
|
[[ -n "${live}" ]] && printf '%s' "${live}" > "$(account_creds "${cur}")"
|
|
561
732
|
}
|
|
562
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
|
+
|
|
563
741
|
cmd_quick() {
|
|
564
742
|
_check_update_bg
|
|
743
|
+
_provider_banner
|
|
565
744
|
_sync_current_creds
|
|
566
745
|
render_table quick "${CREDS_STORE}" "${META_STORE}" "$(current_name)" "$@"
|
|
567
746
|
_show_update_notice
|
|
@@ -607,6 +786,7 @@ cmd_list() {
|
|
|
607
786
|
}
|
|
608
787
|
|
|
609
788
|
_cmd_status_once() {
|
|
789
|
+
_provider_banner
|
|
610
790
|
_sync_current_creds
|
|
611
791
|
local current; current=$(current_name)
|
|
612
792
|
hdr "Current Status"
|
|
@@ -784,12 +964,203 @@ PYEOF
|
|
|
784
964
|
fi
|
|
785
965
|
}
|
|
786
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
|
+
|
|
787
1154
|
cmd_add() {
|
|
788
1155
|
local name="${1:-}"
|
|
789
1156
|
[[ -z "${name}" ]] && { err "usage: relay add <name>"; exit 1; }
|
|
790
1157
|
case "${name}" in
|
|
791
1158
|
*[!a-zA-Z0-9_-]*) err "name must contain only letters, numbers, underscores, or hyphens"; exit 1 ;;
|
|
792
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
|
|
793
1164
|
require_claude
|
|
794
1165
|
if account_exists "${name}"; then
|
|
795
1166
|
warn "Account '${name}' already exists"
|
|
@@ -2366,6 +2737,7 @@ cmd_help() {
|
|
|
2366
2737
|
printf " %-32s %s\n" " relay save <name>" "save current login state"
|
|
2367
2738
|
printf " %-32s %s\n" " relay rename <old> <new>" "rename an account"
|
|
2368
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"
|
|
2369
2741
|
printf " %-32s %s\n" " relay list" "full list with weekly usage"
|
|
2370
2742
|
printf " %-32s %s\n" " relay list -f" "live-refresh mode (Ctrl+C to exit)"
|
|
2371
2743
|
printf " %-32s %s\n" " relay list --no-usage" "list without querying API"
|
|
@@ -2381,6 +2753,13 @@ cmd_help() {
|
|
|
2381
2753
|
printf " %-32s %s\n" " relay autoswitch start/stop" "manage background daemon"
|
|
2382
2754
|
printf " %-32s %s\n" " relay autoswitch status" "daemon state + thresholds"
|
|
2383
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 ""
|
|
2384
2763
|
printf " ${B}Warmup${R}\n"
|
|
2385
2764
|
printf " %-32s %s\n" " relay warmup add <acct> <HH:MM>" "pre-warm an account's 5hr window daily"
|
|
2386
2765
|
printf " %-32s %s\n" " relay warmup remove <acct> [HH:MM]" "remove a warmup schedule"
|
|
@@ -2427,6 +2806,18 @@ case "${CMD}" in
|
|
|
2427
2806
|
remove|rm|del) cmd_remove "$@" ;;
|
|
2428
2807
|
rename|mv) cmd_rename "$@" ;;
|
|
2429
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 "$@" ;;
|
|
2430
2821
|
sessions|sess) cmd_sessions ;;
|
|
2431
2822
|
autoswitch|as) cmd_autoswitch "$@" ;;
|
|
2432
2823
|
lock) cmd_lock "$@" ;;
|