@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.
Files changed (3) hide show
  1. package/README.md +38 -1
  2. package/package.json +1 -1
  3. package/relay +393 -2
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # relay
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dst-justin/relay",
3
- "version": "2.3.1",
3
+ "version": "2.4.0",
4
4
  "description": "Multi-account switcher for Claude Code — instant credential swap across macOS, Linux, and Windows",
5
5
  "bin": {
6
6
  "relay": "./relay.js"
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 "$@" ;;