@wangxt0223/codex-switcher 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.2 - 2026-04-12
4
+
5
+ - Added usage-API proxy auto-detection (manual proxy > env proxy > macOS system proxy).
6
+ - Added `proxy` source display: `(manual)`, `(auto:env)`, `(auto:system)`, or `off`.
7
+ - Kept proxy scope limited to usage API calls used by `list`/`proxy test`.
8
+ - Added smoke-test coverage for env-proxy auto-detection and isolated proxy behavior.
9
+
10
+ ## 0.6.1 - 2026-04-12
11
+
12
+ - Fixed symlink invocation path resolution for `codex-sw` / `codex-switcher`, so global npm installs can always find bundled scripts.
13
+ - Fixed `list` row field reuse bug that could leak previous row values into later rows.
14
+ - Improved usage API request compatibility by adding `ChatGPT-Account-Id` and browser-like headers, while keeping local sessions fallback.
15
+ - Added smoke-test coverage for symlink launch path behavior.
16
+
3
17
  ## 0.6.0 - 2026-04-12
4
18
 
5
19
  - Refactored core model from profile-based switching to `env + account` with built-in `default=~/.codex`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wangxt0223/codex-switcher",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "Env + account switcher for Codex CLI and Codex App with shared env data and per-account auth.",
5
5
  "license": "MIT",
6
6
  "author": "wangxt",
@@ -43,5 +43,8 @@
43
43
  "publishConfig": {
44
44
  "access": "public",
45
45
  "registry": "https://registry.npmjs.org/"
46
+ },
47
+ "dependencies": {
48
+ "@wangxt0223/codex-switcher": "^0.6.0"
46
49
  }
47
50
  }
@@ -34,6 +34,8 @@ codex-switcher account use <account> [--env <env>] [--target cli|app|both] [--sy
34
34
  codex-switcher account logout [account] [--env <env>] [--target cli|app|both]
35
35
  codex-switcher account current [cli|app]
36
36
 
37
+ codex-switcher proxy [<host:port>|off|test]
38
+
37
39
  codex-switcher list
38
40
  codex-switcher status
39
41
  codex-switcher current [cli|app]
@@ -77,6 +79,8 @@ Usage data strategy:
77
79
  - API first (`chatgpt.com/backend-api/wham/usage`)
78
80
  - Auto fallback to local `sessions/*.jsonl` on API failure
79
81
  - `LAST ACTIVITY` appends source marker `(api)` or `(local)`
82
+ - You can configure a dedicated proxy for this usage API via `codex-switcher proxy 127.0.0.1:7899` (only affects `list`)
83
+ - If no manual proxy is configured, env/system proxy settings are auto-detected for usage API requests
80
84
 
81
85
  ## Compatibility Commands
82
86
 
@@ -34,6 +34,8 @@ codex-switcher account use <account> [--env <env>] [--target cli|app|both] [--sy
34
34
  codex-switcher account logout [account] [--env <env>] [--target cli|app|both]
35
35
  codex-switcher account current [cli|app]
36
36
 
37
+ codex-switcher proxy [<host:port>|off|test]
38
+
37
39
  codex-switcher list
38
40
  codex-switcher status
39
41
  codex-switcher current [cli|app]
@@ -77,6 +79,8 @@ codex-switcher account use corp --env project-a
77
79
  - 默认优先 API(`chatgpt.com/backend-api/wham/usage`)
78
80
  - API 失败自动回退本地 `sessions/*.jsonl`
79
81
  - `LAST ACTIVITY` 列会追加 `(api)` 或 `(local)` 标记来源
82
+ - 可通过 `codex-switcher proxy 127.0.0.1:7899` 为该用量 API 单独配置代理(仅影响 `list`)
83
+ - 未手动设置时会自动检测环境变量/系统代理并用于用量 API 请求
80
84
 
81
85
  ## 兼容命令
82
86
 
@@ -1,5 +1,17 @@
1
1
  #!/usr/bin/env bash
2
2
  set -euo pipefail
3
- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
3
+
4
+ resolve_script_dir() {
5
+ local src="${BASH_SOURCE[0]}"
6
+ local dir
7
+ while [[ -h "$src" ]]; do
8
+ dir="$(cd -P "$(dirname "$src")" && pwd)"
9
+ src="$(readlink "$src")"
10
+ [[ "$src" == /* ]] || src="$dir/$src"
11
+ done
12
+ cd -P "$(dirname "$src")" && pwd
13
+ }
14
+
15
+ SCRIPT_DIR="$(resolve_script_dir)"
4
16
  export CODEX_SWITCHER_INVOKED_AS="codex-sw"
5
17
  exec "$SCRIPT_DIR/codex-switcher" "$@"
@@ -1,6 +1,19 @@
1
1
  #!/usr/bin/env bash
2
2
  set -euo pipefail
3
3
 
4
+ resolve_script_dir() {
5
+ local src="${BASH_SOURCE[0]}"
6
+ local dir
7
+ while [[ -h "$src" ]]; do
8
+ dir="$(cd -P "$(dirname "$src")" && pwd)"
9
+ src="$(readlink "$src")"
10
+ [[ "$src" == /* ]] || src="$dir/$src"
11
+ done
12
+ cd -P "$(dirname "$src")" && pwd
13
+ }
14
+
15
+ SCRIPT_DIR="$(resolve_script_dir)"
16
+
4
17
  STATE_DIR="${CODEX_SWITCHER_STATE_DIR:-$HOME/.codex-switcher}"
5
18
  ENVS_DIR="${CODEX_SWITCHER_ENVS_DIR:-$HOME/.codex-envs}"
6
19
  ACCOUNTS_DIR="${CODEX_SWITCHER_ACCOUNTS_DIR:-$STATE_DIR/env-accounts}"
@@ -15,6 +28,9 @@ LOCK_DIR="$STATE_DIR/.lock"
15
28
  LOCK_WAIT_SECONDS="${CODEX_SWITCHER_LOCK_WAIT_SECONDS:-10}"
16
29
 
17
30
  SCRIPT_NAME="${CODEX_SWITCHER_INVOKED_AS:-$(basename "$0")}"
31
+ USAGE_API_ENDPOINT="https://chatgpt.com/backend-api/wham/usage"
32
+ USAGE_PROXY_FILE="$STATE_DIR/usage_proxy"
33
+ DISABLE_SYSTEM_PROXY_DETECT="${CODEX_SWITCHER_DISABLE_SYSTEM_PROXY_DETECT:-false}"
18
34
 
19
35
  usage() {
20
36
  cat <<'USAGE'
@@ -34,6 +50,8 @@ Usage:
34
50
  codex-sw account logout [account] [--env <env>] [--target cli|app|both]
35
51
  codex-sw account current [cli|app]
36
52
 
53
+ codex-sw proxy [<host:port>|off|test]
54
+
37
55
  codex-sw list
38
56
  codex-sw status
39
57
  codex-sw current [cli|app]
@@ -66,6 +84,8 @@ Compatibility:
66
84
  Notes:
67
85
  - Built-in env "default" always maps to ~/.codex (or CODEX_SWITCHER_DEFAULT_HOME).
68
86
  - Same-env account switch only swaps auth.json and does not sync shared data.
87
+ - `proxy` only affects usage API calls used by `list`.
88
+ - If manual proxy is not set, usage API proxy is auto-detected from env/system proxy settings.
69
89
  - `list` prints: EMAIL / PLAN / 5H USAGE / WEEKLY USAGE / LAST ACTIVITY (+ env/account context).
70
90
  - Usage metrics are API-first and automatically fallback to local sessions; source is shown as (api|local).
71
91
  USAGE
@@ -85,9 +105,8 @@ now_utc() {
85
105
  }
86
106
 
87
107
  switcher_version() {
88
- local script_dir package_json version
89
- script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
90
- package_json="$(cd "$script_dir/../../.." && pwd)/package.json"
108
+ local package_json version
109
+ package_json="$(cd "$SCRIPT_DIR/../../.." && pwd)/package.json"
91
110
 
92
111
  if [[ -f "$package_json" ]]; then
93
112
  version="$(sed -nE 's/^[[:space:]]*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' "$package_json" | head -n 1)"
@@ -185,6 +204,116 @@ current_account_file() {
185
204
  echo "$STATE_DIR/current_${target}_account"
186
205
  }
187
206
 
207
+ normalize_usage_proxy_value() {
208
+ local raw="${1:-}"
209
+ local value
210
+ value="$(echo "$raw" | tr -d '\r' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
211
+ [[ -n "$value" ]] || return 1
212
+ [[ "$value" != *[[:space:]]* ]] || return 1
213
+ case "$value" in
214
+ http://*|https://*|socks5://*)
215
+ echo "$value"
216
+ ;;
217
+ *)
218
+ echo "http://$value"
219
+ ;;
220
+ esac
221
+ }
222
+
223
+ read_usage_proxy() {
224
+ local value
225
+ [[ -f "$USAGE_PROXY_FILE" ]] || return 1
226
+ value="$(head -n 1 "$USAGE_PROXY_FILE" 2>/dev/null || true)"
227
+ normalize_usage_proxy_value "$value"
228
+ }
229
+
230
+ detect_usage_proxy_from_env() {
231
+ local key raw normalized
232
+ for key in CODEX_SWITCHER_USAGE_PROXY HTTPS_PROXY https_proxy HTTP_PROXY http_proxy ALL_PROXY all_proxy; do
233
+ raw="${!key:-}"
234
+ [[ -n "$raw" ]] || continue
235
+ normalized="$(normalize_usage_proxy_value "$raw" || true)"
236
+ [[ -n "$normalized" ]] || continue
237
+ echo "$normalized"
238
+ return 0
239
+ done
240
+ return 1
241
+ }
242
+
243
+ detect_usage_proxy_from_system_macos() {
244
+ [[ "$DISABLE_SYSTEM_PROXY_DETECT" != "true" ]] || return 1
245
+ [[ "$(uname -s 2>/dev/null || true)" == "Darwin" ]] || return 1
246
+ command -v scutil >/dev/null 2>&1 || return 1
247
+
248
+ local dump host port enabled normalized
249
+ dump="$(scutil --proxy 2>/dev/null || true)"
250
+ [[ -n "$dump" ]] || return 1
251
+
252
+ host="$(echo "$dump" | sed -nE 's/^[[:space:]]*HTTPSProxy[[:space:]]*:[[:space:]]*(.+)$/\1/p' | head -n 1)"
253
+ port="$(echo "$dump" | sed -nE 's/^[[:space:]]*HTTPSPort[[:space:]]*:[[:space:]]*([0-9]+)$/\1/p' | head -n 1)"
254
+ enabled="$(echo "$dump" | sed -nE 's/^[[:space:]]*HTTPSEnable[[:space:]]*:[[:space:]]*([01])$/\1/p' | head -n 1)"
255
+ if [[ "$enabled" == "1" && -n "$host" && -n "$port" ]]; then
256
+ normalized="$(normalize_usage_proxy_value "http://$host:$port" || true)"
257
+ [[ -n "$normalized" ]] && echo "$normalized" && return 0
258
+ fi
259
+
260
+ host="$(echo "$dump" | sed -nE 's/^[[:space:]]*HTTPProxy[[:space:]]*:[[:space:]]*(.+)$/\1/p' | head -n 1)"
261
+ port="$(echo "$dump" | sed -nE 's/^[[:space:]]*HTTPPort[[:space:]]*:[[:space:]]*([0-9]+)$/\1/p' | head -n 1)"
262
+ enabled="$(echo "$dump" | sed -nE 's/^[[:space:]]*HTTPEnable[[:space:]]*:[[:space:]]*([01])$/\1/p' | head -n 1)"
263
+ if [[ "$enabled" == "1" && -n "$host" && -n "$port" ]]; then
264
+ normalized="$(normalize_usage_proxy_value "http://$host:$port" || true)"
265
+ [[ -n "$normalized" ]] && echo "$normalized" && return 0
266
+ fi
267
+
268
+ host="$(echo "$dump" | sed -nE 's/^[[:space:]]*SOCKSProxy[[:space:]]*:[[:space:]]*(.+)$/\1/p' | head -n 1)"
269
+ port="$(echo "$dump" | sed -nE 's/^[[:space:]]*SOCKSPort[[:space:]]*:[[:space:]]*([0-9]+)$/\1/p' | head -n 1)"
270
+ enabled="$(echo "$dump" | sed -nE 's/^[[:space:]]*SOCKSEnable[[:space:]]*:[[:space:]]*([01])$/\1/p' | head -n 1)"
271
+ if [[ "$enabled" == "1" && -n "$host" && -n "$port" ]]; then
272
+ normalized="$(normalize_usage_proxy_value "socks5://$host:$port" || true)"
273
+ [[ -n "$normalized" ]] && echo "$normalized" && return 0
274
+ fi
275
+
276
+ return 1
277
+ }
278
+
279
+ resolve_usage_proxy_with_source() {
280
+ local value
281
+
282
+ value="$(read_usage_proxy || true)"
283
+ if [[ -n "$value" ]]; then
284
+ printf 'manual\t%s\n' "$value"
285
+ return 0
286
+ fi
287
+
288
+ value="$(detect_usage_proxy_from_env || true)"
289
+ if [[ -n "$value" ]]; then
290
+ printf 'auto-env\t%s\n' "$value"
291
+ return 0
292
+ fi
293
+
294
+ value="$(detect_usage_proxy_from_system_macos || true)"
295
+ if [[ -n "$value" ]]; then
296
+ printf 'auto-system\t%s\n' "$value"
297
+ return 0
298
+ fi
299
+
300
+ return 1
301
+ }
302
+
303
+ set_usage_proxy() {
304
+ local value="$1"
305
+ local file tmp
306
+ file="$USAGE_PROXY_FILE"
307
+ tmp="${file}.tmp"
308
+ printf '%s\n' "$value" > "$tmp"
309
+ chmod 600 "$tmp" 2>/dev/null || true
310
+ mv "$tmp" "$file"
311
+ }
312
+
313
+ clear_usage_proxy() {
314
+ rm -f -- "$USAGE_PROXY_FILE"
315
+ }
316
+
188
317
  set_current_env() {
189
318
  local target="$1"
190
319
  local env="$2"
@@ -567,19 +696,22 @@ app_stop_managed() {
567
696
  }
568
697
 
569
698
  metrics_script_path() {
570
- local script_dir
571
- script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
572
- echo "$script_dir/profile-metrics.py"
699
+ echo "$SCRIPT_DIR/profile-metrics.py"
573
700
  }
574
701
 
575
702
  collect_profile_metrics_tsv() {
576
703
  local account="$1"
577
704
  local auth_file="$2"
578
705
  local data_path="$3"
706
+ local usage_proxy="${4:-}"
579
707
  local script_path
580
708
  script_path="$(metrics_script_path)"
581
709
  [[ -f "$script_path" ]] || return 1
582
- python3 "$script_path" --account-name "$account" --auth-file "$auth_file" --data-path "$data_path" 2>/dev/null
710
+ if [[ -n "$usage_proxy" ]]; then
711
+ python3 "$script_path" --account-name "$account" --auth-file "$auth_file" --data-path "$data_path" --usage-proxy "$usage_proxy" 2>/dev/null
712
+ else
713
+ python3 "$script_path" --account-name "$account" --auth-file "$auth_file" --data-path "$data_path" 2>/dev/null
714
+ fi
583
715
  }
584
716
 
585
717
  cmd_env_list() {
@@ -914,15 +1046,104 @@ cmd_account_current() {
914
1046
  esac
915
1047
  }
916
1048
 
1049
+ cmd_proxy_show() {
1050
+ local info source value
1051
+ info="$(resolve_usage_proxy_with_source || true)"
1052
+ if [[ -z "$info" ]]; then
1053
+ echo "usage_api_proxy: off"
1054
+ return 0
1055
+ fi
1056
+ IFS=$'\t' read -r source value <<< "$info"
1057
+ case "$source" in
1058
+ manual)
1059
+ echo "usage_api_proxy: $value (manual)"
1060
+ ;;
1061
+ auto-env)
1062
+ echo "usage_api_proxy: $value (auto:env)"
1063
+ ;;
1064
+ auto-system)
1065
+ echo "usage_api_proxy: $value (auto:system)"
1066
+ ;;
1067
+ *)
1068
+ echo "usage_api_proxy: $value"
1069
+ ;;
1070
+ esac
1071
+ }
1072
+
1073
+ cmd_proxy_set() {
1074
+ local raw="$1"
1075
+ local normalized
1076
+ normalized="$(normalize_usage_proxy_value "$raw")" || err "invalid proxy '$raw' (expected host:port or scheme://host:port)"
1077
+ set_usage_proxy "$normalized"
1078
+ log_event INFO "usage_proxy_set value=$normalized"
1079
+ echo "Set usage API proxy: $normalized"
1080
+ }
1081
+
1082
+ cmd_proxy_off() {
1083
+ clear_usage_proxy
1084
+ log_event INFO "usage_proxy_off"
1085
+ echo "Manual usage API proxy disabled"
1086
+ }
1087
+
1088
+ cmd_proxy_test() {
1089
+ local proxy env_name account auth_file token body_file http_code body_preview info source
1090
+ info="$(resolve_usage_proxy_with_source || true)"
1091
+ [[ -n "$info" ]] || err "usage API proxy is off and no auto proxy detected. run: $SCRIPT_NAME proxy <host:port>"
1092
+ IFS=$'\t' read -r source proxy <<< "$info"
1093
+
1094
+ env_name="$(read_current_env cli || true)"
1095
+ account="$(read_current_account cli || true)"
1096
+ auth_file="$(account_auth_path "$env_name" "$account")"
1097
+ [[ -f "$auth_file" ]] || auth_file="$(env_home_path "$env_name")/auth.json"
1098
+ [[ -f "$auth_file" ]] || err "auth.json not found for current CLI env/account: $env_name/$account"
1099
+
1100
+ token="$(python3 - "$auth_file" <<'PY'
1101
+ import json, sys
1102
+ path = sys.argv[1]
1103
+ try:
1104
+ with open(path, "r", encoding="utf-8") as f:
1105
+ data = json.load(f)
1106
+ token = ((data.get("tokens") or {}).get("access_token") or "").strip()
1107
+ print(token)
1108
+ except Exception:
1109
+ print("")
1110
+ PY
1111
+ )"
1112
+ [[ -n "$token" ]] || err "access_token missing in $auth_file"
1113
+
1114
+ body_file="$(mktemp /tmp/codex-sw-proxy-test.XXXXXX)"
1115
+ http_code="$(HTTPS_PROXY="$proxy" HTTP_PROXY="$proxy" curl -sS -o "$body_file" -w '%{http_code}' \
1116
+ -H "Authorization: Bearer $token" \
1117
+ -H "Accept: application/json" \
1118
+ -H "User-Agent: Mozilla/5.0" \
1119
+ "$USAGE_API_ENDPOINT" || true)"
1120
+ body_preview="$(head -c 160 "$body_file" 2>/dev/null || true)"
1121
+ rm -f -- "$body_file"
1122
+
1123
+ if [[ "$http_code" == "200" ]]; then
1124
+ echo "usage_api_proxy_test: ok (http=200, source=$source, proxy=$proxy, env/account=$env_name/$account)"
1125
+ return 0
1126
+ fi
1127
+
1128
+ echo "usage_api_proxy_test: failed (http=${http_code:-000}, source=$source, proxy=$proxy, env/account=$env_name/$account)" >&2
1129
+ [[ -n "$body_preview" ]] && echo "response_preview: $body_preview" >&2
1130
+ return 1
1131
+ }
1132
+
917
1133
  cmd_list() {
918
1134
  local cli_env app_env cli_account app_account
919
1135
  local env account marks auth_file home metrics
920
1136
  local email plan usage_5h usage_weekly last_activity source
1137
+ local usage_proxy="" usage_info="" usage_proxy_source=""
921
1138
 
922
1139
  cli_env="$(read_current_env cli || true)"
923
1140
  app_env="$(read_current_env app || true)"
924
1141
  cli_account="$(read_current_account cli || true)"
925
1142
  app_account="$(read_current_account app || true)"
1143
+ usage_info="$(resolve_usage_proxy_with_source || true)"
1144
+ if [[ -n "$usage_info" ]]; then
1145
+ IFS=$'\t' read -r usage_proxy_source usage_proxy <<< "$usage_info"
1146
+ fi
926
1147
 
927
1148
  printf '%-12s %-30s %-44s %-10s %-16s %-18s %s\n' "ENV" "ACCOUNT" "EMAIL" "PLAN" "5H USAGE" "WEEKLY USAGE" "LAST ACTIVITY"
928
1149
  while IFS= read -r env; do
@@ -931,13 +1152,19 @@ cmd_list() {
931
1152
  local found=0
932
1153
  while IFS= read -r account; do
933
1154
  found=1
1155
+ email=""
1156
+ plan=""
1157
+ usage_5h=""
1158
+ usage_weekly=""
1159
+ last_activity=""
1160
+ source=""
934
1161
  marks=""
935
1162
  [[ "$env" == "$cli_env" && "$account" == "$cli_account" ]] && marks="${marks} [cli-current]"
936
1163
  [[ "$env" == "$app_env" && "$account" == "$app_account" ]] && marks="${marks} [app-current]"
937
1164
  auth_file="$(account_auth_path "$env" "$account")"
938
1165
  [[ -f "$auth_file" ]] || auth_file="$home/auth.json"
939
1166
 
940
- metrics="$(collect_profile_metrics_tsv "$account" "$auth_file" "$home" || true)"
1167
+ metrics="$(collect_profile_metrics_tsv "$account" "$auth_file" "$home" "$usage_proxy" || true)"
941
1168
  if [[ -n "$metrics" ]]; then
942
1169
  IFS=$'\t' read -r email plan usage_5h usage_weekly last_activity source <<< "$metrics"
943
1170
  fi
@@ -954,7 +1181,13 @@ cmd_list() {
954
1181
  done < <(list_accounts_for_env "$env")
955
1182
 
956
1183
  if [[ "$found" -eq 0 ]]; then
957
- metrics="$(collect_profile_metrics_tsv "-" "$home/auth.json" "$home" || true)"
1184
+ email=""
1185
+ plan=""
1186
+ usage_5h=""
1187
+ usage_weekly=""
1188
+ last_activity=""
1189
+ source=""
1190
+ metrics="$(collect_profile_metrics_tsv "-" "$home/auth.json" "$home" "$usage_proxy" || true)"
958
1191
  if [[ -n "$metrics" ]]; then
959
1192
  IFS=$'\t' read -r email plan usage_5h usage_weekly last_activity source <<< "$metrics"
960
1193
  fi
@@ -1242,7 +1475,7 @@ cmd_init() {
1242
1475
  ;;
1243
1476
  esac
1244
1477
 
1245
- script_abs="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")"
1478
+ script_abs="$SCRIPT_DIR/codex-switcher"
1246
1479
  target_link="$HOME/.local/bin/codex-sw"
1247
1480
  block_start="# >>> codex-sw init >>>"
1248
1481
  block_end="# <<< codex-sw init <<<"
@@ -1700,6 +1933,23 @@ main() {
1700
1933
  ;;
1701
1934
  esac
1702
1935
  ;;
1936
+ proxy)
1937
+ [[ "$#" -le 1 ]] || err "usage: $SCRIPT_NAME proxy [<host:port>|off|test]"
1938
+ case "${1:-}" in
1939
+ "")
1940
+ cmd_proxy_show
1941
+ ;;
1942
+ off)
1943
+ with_lock cmd_proxy_off
1944
+ ;;
1945
+ test)
1946
+ cmd_proxy_test
1947
+ ;;
1948
+ *)
1949
+ with_lock cmd_proxy_set "$1"
1950
+ ;;
1951
+ esac
1952
+ ;;
1703
1953
  add)
1704
1954
  [[ "$#" -eq 1 ]] || err "usage: $SCRIPT_NAME add <account>"
1705
1955
  with_lock cmd_add "$1"
@@ -11,6 +11,10 @@ from typing import Any, Dict, Optional
11
11
 
12
12
 
13
13
  USAGE_ENDPOINT = "https://chatgpt.com/backend-api/wham/usage"
14
+ DEFAULT_USER_AGENT = (
15
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
16
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36"
17
+ )
14
18
  DASH = "-"
15
19
  KNOWN_PLANS = {"free", "plus", "pro", "team", "business", "enterprise", "edu"}
16
20
 
@@ -20,6 +24,7 @@ def parse_args() -> argparse.Namespace:
20
24
  parser.add_argument("--account-name", required=True)
21
25
  parser.add_argument("--auth-file", required=True)
22
26
  parser.add_argument("--data-path", required=True)
27
+ parser.add_argument("--usage-proxy", default="")
23
28
  parser.add_argument("--timeout-seconds", type=int, default=4)
24
29
  return parser.parse_args()
25
30
 
@@ -165,7 +170,7 @@ def extract_windows_from_usage_blob(blob: Dict[str, Any]) -> Dict[int, Dict[str,
165
170
  return windows
166
171
 
167
172
 
168
- def request_usage(access_token: str, timeout_seconds: int) -> Optional[Dict[str, Any]]:
173
+ def request_usage(access_token: str, account_id: str, usage_proxy: str, timeout_seconds: int) -> Optional[Dict[str, Any]]:
169
174
  if not access_token:
170
175
  return None
171
176
  cmd = [
@@ -177,15 +182,27 @@ def request_usage(access_token: str, timeout_seconds: int) -> Optional[Dict[str,
177
182
  str(max(2, timeout_seconds + 2)),
178
183
  "-H",
179
184
  f"Authorization: Bearer {access_token}",
180
- USAGE_ENDPOINT,
185
+ "-H",
186
+ "Accept: application/json",
187
+ "-H",
188
+ f"User-Agent: {DEFAULT_USER_AGENT}",
181
189
  ]
190
+ if account_id:
191
+ cmd.extend(["-H", f"ChatGPT-Account-Id: {account_id}"])
192
+ cmd.append(USAGE_ENDPOINT)
182
193
  try:
194
+ env = os.environ.copy()
195
+ proxy = (usage_proxy or "").strip()
196
+ if proxy:
197
+ env["HTTPS_PROXY"] = proxy
198
+ env["HTTP_PROXY"] = proxy
183
199
  result = subprocess.run(
184
200
  cmd,
185
201
  check=False,
186
202
  stdout=subprocess.PIPE,
187
203
  stderr=subprocess.PIPE,
188
204
  text=True,
205
+ env=env,
189
206
  )
190
207
  except Exception:
191
208
  return None
@@ -276,8 +293,8 @@ def collect_local_metrics(data_path: str) -> Dict[str, Any]:
276
293
  return {"source": "local"}
277
294
 
278
295
 
279
- def collect_api_metrics(access_token: str, timeout_seconds: int) -> Optional[Dict[str, Any]]:
280
- payload = request_usage(access_token, timeout_seconds)
296
+ def collect_api_metrics(access_token: str, account_id: str, usage_proxy: str, timeout_seconds: int) -> Optional[Dict[str, Any]]:
297
+ payload = request_usage(access_token, account_id, usage_proxy, timeout_seconds)
281
298
  if not payload:
282
299
  return None
283
300
 
@@ -318,6 +335,9 @@ def main() -> int:
318
335
  access_token = tokens.get("access_token")
319
336
  if not isinstance(access_token, str):
320
337
  access_token = ""
338
+ account_id = tokens.get("account_id")
339
+ if not isinstance(account_id, str):
340
+ account_id = ""
321
341
  id_token = tokens.get("id_token")
322
342
  if not isinstance(id_token, str):
323
343
  id_token = ""
@@ -331,7 +351,7 @@ def main() -> int:
331
351
  if plan_from_claims == "unknown":
332
352
  plan_from_claims = normalize_plan(auth_data.get("chatgpt_plan_type") or auth_data.get("plan_type"))
333
353
 
334
- api_metrics = collect_api_metrics(access_token, args.timeout_seconds)
354
+ api_metrics = collect_api_metrics(access_token, account_id, args.usage_proxy, args.timeout_seconds)
335
355
  metrics = api_metrics if api_metrics is not None else collect_local_metrics(args.data_path)
336
356
 
337
357
  windows = metrics.get("windows")
@@ -3,6 +3,7 @@ set -euo pipefail
3
3
 
4
4
  ROOT="$(cd "$(dirname "$0")/.." && pwd)"
5
5
  SW="$ROOT/scripts/codex-sw"
6
+ SW_LINK=""
6
7
 
7
8
  bash -n "$SW"
8
9
 
@@ -12,6 +13,8 @@ ENVS="$TMPBASE/envs"
12
13
  DEFAULT_HOME="$TMPBASE/default-home"
13
14
  BIN="$TMPBASE/bin"
14
15
  mkdir -p "$BIN" "$DEFAULT_HOME"
16
+ SW_LINK="$BIN/codex-sw-link"
17
+ ln -s "$SW" "$SW_LINK"
15
18
 
16
19
  cleanup() {
17
20
  pkill -f "$BIN/fake-codex-app" >/dev/null 2>&1 || true
@@ -63,6 +66,7 @@ cat > "$BIN/curl" <<'CURL'
63
66
  #!/usr/bin/env bash
64
67
  set -euo pipefail
65
68
  mode="${CODEX_SWITCHER_TEST_CURL_MODE:-success}"
69
+ echo "proxy=${HTTPS_PROXY:-}" >> "${CODEX_SWITCHER_TEST_CURL_LOG:?}"
66
70
  if [[ "$mode" == "success" ]]; then
67
71
  cat <<'JSON'
68
72
  {"rate_limit":{"plan_type":"plus","primary_window":{"used_percent":40,"limit_window_seconds":18000,"reset_at":"2099-01-01T06:30:00Z"},"secondary_window":{"used_percent":20,"limit_window_seconds":604800,"reset_at":"2099-01-03T08:00:00Z"}},"last_activity_at":"2099-01-01T04:30:00Z"}
@@ -81,16 +85,26 @@ export CODEX_SWITCHER_ACCOUNTS_DIR="$STATE/env-accounts"
81
85
  export CODEX_SWITCHER_APP_BIN="$BIN/fake-codex-app"
82
86
  export CODEX_SWITCHER_LOCK_WAIT_SECONDS=2
83
87
  export CODEX_SWITCHER_DEFAULT_HOME="$DEFAULT_HOME"
88
+ export CODEX_SWITCHER_DISABLE_SYSTEM_PROXY_DETECT=true
84
89
  export CODEX_SWITCHER_TEST_NPM_LOG="$TMPBASE/npm-args.log"
85
90
  export CODEX_SWITCHER_TEST_CODEX_LOG="$TMPBASE/codex-args.log"
91
+ export CODEX_SWITCHER_TEST_CURL_LOG="$TMPBASE/curl-args.log"
86
92
  export CODEX_SWITCHER_TEST_CURL_MODE="success"
93
+ unset HTTPS_PROXY https_proxy HTTP_PROXY http_proxy ALL_PROXY all_proxy
87
94
  : > "$CODEX_SWITCHER_TEST_CODEX_LOG"
95
+ : > "$CODEX_SWITCHER_TEST_CURL_LOG"
88
96
 
89
97
  echo '{"memo":"persist"}' > "$DEFAULT_HOME/shared.json"
90
98
 
91
99
  check_out="$("$SW" check)"
92
100
  echo "$check_out" | grep -Eq '^version: [0-9]+\.[0-9]+\.[0-9]+$'
93
101
  echo "$check_out" | grep -q "check: ok"
102
+ link_check_out="$("$SW_LINK" check)"
103
+ echo "$link_check_out" | grep -Eq '^version: [0-9]+\.[0-9]+\.[0-9]+$'
104
+ echo "$link_check_out" | grep -q "check: ok"
105
+ [[ "$("$SW" proxy)" == "usage_api_proxy: off" ]]
106
+ "$SW" proxy 127.0.0.1:7899
107
+ [[ "$("$SW" proxy)" == "usage_api_proxy: http://127.0.0.1:7899 (manual)" ]]
94
108
  init_out="$("$SW" init --dry-run)"
95
109
  echo "$init_out" | grep -q "\[dry-run\]"
96
110
  "$SW" upgrade
@@ -170,16 +184,33 @@ grep -q "(personal)personal@example.com" /tmp/codex_sw_list_api
170
184
  grep -q "(api)" /tmp/codex_sw_list_api
171
185
  grep -q "60% (" /tmp/codex_sw_list_api
172
186
  grep -q "80% (" /tmp/codex_sw_list_api
187
+ grep -q "proxy=http://127.0.0.1:7899" "$CODEX_SWITCHER_TEST_CURL_LOG"
188
+
189
+ "$SW_LINK" list >/tmp/codex_sw_list_symlink
190
+ grep -q "(personal)personal@example.com" /tmp/codex_sw_list_symlink
191
+ grep -q "(api)" /tmp/codex_sw_list_symlink
173
192
 
174
193
  mkdir -p "$ENVS/project/home/sessions/2026/04/12"
175
194
  cat > "$ENVS/project/home/sessions/2026/04/12/rollout-test.jsonl" <<'JSONL'
176
195
  {"timestamp":"2026-04-12T09:00:00Z","type":"event_msg","payload":{"type":"token_count","rate_limits":{"plan_type":"business","primary":{"used_percent":25,"window_minutes":300,"resets_at":1776004200},"secondary":{"used_percent":70,"window_minutes":10080,"resets_at":1776519000}}}}
177
196
  JSONL
178
197
  export CODEX_SWITCHER_TEST_CURL_MODE="fail"
198
+ "$SW" proxy off
199
+ [[ "$("$SW" proxy)" == "usage_api_proxy: off" ]]
200
+ : > "$CODEX_SWITCHER_TEST_CURL_LOG"
179
201
  "$SW" list >/tmp/codex_sw_list_local
180
202
  grep -q "(local)" /tmp/codex_sw_list_local
181
203
  grep -q "75% (" /tmp/codex_sw_list_local
182
204
  grep -q "30% (" /tmp/codex_sw_list_local
205
+ grep -q "^proxy=$" "$CODEX_SWITCHER_TEST_CURL_LOG"
206
+
207
+ export CODEX_SWITCHER_TEST_CURL_MODE="success"
208
+ export HTTPS_PROXY="http://10.10.10.10:18080"
209
+ [[ "$("$SW" proxy)" == "usage_api_proxy: http://10.10.10.10:18080 (auto:env)" ]]
210
+ : > "$CODEX_SWITCHER_TEST_CURL_LOG"
211
+ "$SW" list >/tmp/codex_sw_list_auto_env
212
+ grep -q "proxy=http://10.10.10.10:18080" "$CODEX_SWITCHER_TEST_CURL_LOG"
213
+ unset HTTPS_PROXY
183
214
 
184
215
  "$SW" app status >/tmp/codex_sw_app_status_1
185
216
  [[ "$?" -eq 0 ]]