codex-workspaces 0.3.3__tar.gz → 0.3.4__tar.gz

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 (34) hide show
  1. {codex_workspaces-0.3.3 → codex_workspaces-0.3.4}/CHANGELOG.md +25 -0
  2. {codex_workspaces-0.3.3/src/codex_workspaces.egg-info → codex_workspaces-0.3.4}/PKG-INFO +10 -2
  3. {codex_workspaces-0.3.3 → codex_workspaces-0.3.4}/README.MD +9 -1
  4. {codex_workspaces-0.3.3 → codex_workspaces-0.3.4}/README.zh-CN.md +9 -1
  5. {codex_workspaces-0.3.3 → codex_workspaces-0.3.4}/docs/RELEASE.zh-CN.md +2 -2
  6. {codex_workspaces-0.3.3 → codex_workspaces-0.3.4}/docs/TESTING.zh-CN.md +1 -1
  7. {codex_workspaces-0.3.3 → codex_workspaces-0.3.4}/pyproject.toml +1 -1
  8. {codex_workspaces-0.3.3 → codex_workspaces-0.3.4}/src/codex_workspaces/__init__.py +1 -1
  9. {codex_workspaces-0.3.3 → codex_workspaces-0.3.4}/src/codex_workspaces/cli.py +14 -7
  10. {codex_workspaces-0.3.3 → codex_workspaces-0.3.4}/src/codex_workspaces/core.py +179 -20
  11. codex_workspaces-0.3.4/src/codex_workspaces/private_api/auth.py +48 -0
  12. {codex_workspaces-0.3.3 → codex_workspaces-0.3.4}/src/codex_workspaces/private_api/client.py +86 -1
  13. {codex_workspaces-0.3.3 → codex_workspaces-0.3.4}/src/codex_workspaces/private_api/errors.py +12 -4
  14. {codex_workspaces-0.3.3 → codex_workspaces-0.3.4}/src/codex_workspaces/private_api/models.py +1 -0
  15. {codex_workspaces-0.3.3 → codex_workspaces-0.3.4}/src/codex_workspaces/store.py +2 -2
  16. {codex_workspaces-0.3.3 → codex_workspaces-0.3.4/src/codex_workspaces.egg-info}/PKG-INFO +10 -2
  17. {codex_workspaces-0.3.3 → codex_workspaces-0.3.4}/tests/test_cli.py +171 -6
  18. {codex_workspaces-0.3.3 → codex_workspaces-0.3.4}/tests/test_core.py +203 -12
  19. codex_workspaces-0.3.3/src/codex_workspaces/private_api/auth.py +0 -45
  20. {codex_workspaces-0.3.3 → codex_workspaces-0.3.4}/MANIFEST.in +0 -0
  21. {codex_workspaces-0.3.3 → codex_workspaces-0.3.4}/docs/DESIGN.zh-CN.md +0 -0
  22. {codex_workspaces-0.3.3 → codex_workspaces-0.3.4}/setup.cfg +0 -0
  23. {codex_workspaces-0.3.3 → codex_workspaces-0.3.4}/src/codex_workspaces/__main__.py +0 -0
  24. {codex_workspaces-0.3.3 → codex_workspaces-0.3.4}/src/codex_workspaces/auth_inspector.py +0 -0
  25. {codex_workspaces-0.3.3 → codex_workspaces-0.3.4}/src/codex_workspaces/config.py +0 -0
  26. {codex_workspaces-0.3.3 → codex_workspaces-0.3.4}/src/codex_workspaces/errors.py +0 -0
  27. {codex_workspaces-0.3.3 → codex_workspaces-0.3.4}/src/codex_workspaces/platforms.py +0 -0
  28. {codex_workspaces-0.3.3 → codex_workspaces-0.3.4}/src/codex_workspaces/private_api/__init__.py +0 -0
  29. {codex_workspaces-0.3.3 → codex_workspaces-0.3.4}/src/codex_workspaces/stats.py +0 -0
  30. {codex_workspaces-0.3.3 → codex_workspaces-0.3.4}/src/codex_workspaces.egg-info/SOURCES.txt +0 -0
  31. {codex_workspaces-0.3.3 → codex_workspaces-0.3.4}/src/codex_workspaces.egg-info/dependency_links.txt +0 -0
  32. {codex_workspaces-0.3.3 → codex_workspaces-0.3.4}/src/codex_workspaces.egg-info/entry_points.txt +0 -0
  33. {codex_workspaces-0.3.3 → codex_workspaces-0.3.4}/src/codex_workspaces.egg-info/requires.txt +0 -0
  34. {codex_workspaces-0.3.3 → codex_workspaces-0.3.4}/src/codex_workspaces.egg-info/top_level.txt +0 -0
@@ -4,6 +4,31 @@ All notable changes to `codex-workspaces` will be documented in this file.
4
4
 
5
5
  This project follows a simple changelog format while it is still pre-release.
6
6
 
7
+ ## 0.3.4 - 2026-07-04
8
+
9
+ ### Added
10
+
11
+ - Added `accounts current --id` and `accounts current --json` for shell-friendly account composition.
12
+
13
+ ### Changed
14
+
15
+ - Default experimental private API quota configuration now points at the ChatGPT WHAM usage endpoint while remaining disabled by default.
16
+ - Updated WHAM quota parsing for `rate_limit.primary_window`, reset times, window duration, allowed/limit status, and credits metadata.
17
+ - Tightened quota auth extraction to use ChatGPT `auth.json` fields (`tokens.access_token`, `tokens.refresh_token`, and `tokens.account_id`) without recursively guessing token fields.
18
+ - Updated README and testing docs to document the current `base_url`, `quota_endpoint`, and `account_endpoint` configuration fields.
19
+
20
+ ### Fixed
21
+
22
+ - Fixed `quota`, `quota --json`, `accounts quota`, and `accounts list -a` so disabled or failing private API quota calls return friendly errors instead of Python tracebacks.
23
+ - Fixed `accounts list -a` so one account's realtime quota failure does not interrupt the whole account list.
24
+ - Fixed `accounts current` command composition by documenting `accounts info "$(codex-workspaces accounts current --id)"`.
25
+ - Fixed WHAM quota requests to include `OpenAI-Account-Id`.
26
+ - Fixed unauthorized and expired-access-token quota failures to return actionable login/refresh hints without leaking credentials.
27
+
28
+ ### Security
29
+
30
+ - Avoids using `id_token` or recursively discovered token-like fields as private API bearer tokens.
31
+
7
32
  ## 0.3.3 - 2026-07-04
8
33
 
9
34
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-workspaces
3
- Version: 0.3.3
3
+ Version: 0.3.4
4
4
  Summary: Cross-platform Codex workspace switcher with a preserved macOS shell workflow.
5
5
  Author: blockchain-project-lives
6
6
  Project-URL: Homepage, https://github.com/blockchain-project-lives/codex-workspaces
@@ -223,9 +223,14 @@ Enable explicitly:
223
223
  ```bash
224
224
  codex-workspaces config set experimental_private_api.enabled true
225
225
  codex-workspaces config set experimental_private_api.quota_enabled true
226
- codex-workspaces config set experimental_private_api.refresh_enabled true
227
226
  ```
228
227
 
228
+ The current endpoint fields are:
229
+
230
+ - `experimental_private_api.base_url` defaults to `https://chatgpt.com`
231
+ - `experimental_private_api.quota_endpoint` defaults to `/backend-api/wham/usage`
232
+ - `experimental_private_api.account_endpoint` defaults to an empty string
233
+
229
234
  Query current account quota:
230
235
 
231
236
  ```bash
@@ -254,6 +259,8 @@ codex-workspaces accounts refresh acct_work
254
259
  codex-workspaces accounts refresh --all --json
255
260
  ```
256
261
 
262
+ Refresh remains disabled by default. The current `account_endpoint` is not the Codex responses API; do not point it at the responses endpoint unless the provider logic is updated to support that POST-based flow.
263
+
257
264
  Realtime quota uses explicit experimental configuration, timeout, serial account iteration, and a local TTL cache under `~/.codex-workspaces/cache/quota/`. The cache stores quota summaries and auth hashes only; it does not store tokens, cookies, authorization headers, or raw `auth.json`.
258
265
 
259
266
  `stats` and `quota` are different: `stats` is local historical usage from SQLite, while `quota` is a realtime remote lookup. Quota failures do not affect local workspace/account switching.
@@ -274,6 +281,7 @@ Manage account metadata and snapshots:
274
281
  ```bash
275
282
  codex-workspaces accounts note acct_research "lab account"
276
283
  codex-workspaces accounts info acct_research
284
+ codex-workspaces accounts info "$(codex-workspaces accounts current --id)"
277
285
  codex-workspaces accounts rename acct_research acct_lab
278
286
  codex-workspaces accounts delete acct_lab --force
279
287
  ```
@@ -195,9 +195,14 @@ Enable explicitly:
195
195
  ```bash
196
196
  codex-workspaces config set experimental_private_api.enabled true
197
197
  codex-workspaces config set experimental_private_api.quota_enabled true
198
- codex-workspaces config set experimental_private_api.refresh_enabled true
199
198
  ```
200
199
 
200
+ The current endpoint fields are:
201
+
202
+ - `experimental_private_api.base_url` defaults to `https://chatgpt.com`
203
+ - `experimental_private_api.quota_endpoint` defaults to `/backend-api/wham/usage`
204
+ - `experimental_private_api.account_endpoint` defaults to an empty string
205
+
201
206
  Query current account quota:
202
207
 
203
208
  ```bash
@@ -226,6 +231,8 @@ codex-workspaces accounts refresh acct_work
226
231
  codex-workspaces accounts refresh --all --json
227
232
  ```
228
233
 
234
+ Refresh remains disabled by default. The current `account_endpoint` is not the Codex responses API; do not point it at the responses endpoint unless the provider logic is updated to support that POST-based flow.
235
+
229
236
  Realtime quota uses explicit experimental configuration, timeout, serial account iteration, and a local TTL cache under `~/.codex-workspaces/cache/quota/`. The cache stores quota summaries and auth hashes only; it does not store tokens, cookies, authorization headers, or raw `auth.json`.
230
237
 
231
238
  `stats` and `quota` are different: `stats` is local historical usage from SQLite, while `quota` is a realtime remote lookup. Quota failures do not affect local workspace/account switching.
@@ -246,6 +253,7 @@ Manage account metadata and snapshots:
246
253
  ```bash
247
254
  codex-workspaces accounts note acct_research "lab account"
248
255
  codex-workspaces accounts info acct_research
256
+ codex-workspaces accounts info "$(codex-workspaces accounts current --id)"
249
257
  codex-workspaces accounts rename acct_research acct_lab
250
258
  codex-workspaces accounts delete acct_lab --force
251
259
  ```
@@ -195,9 +195,14 @@ codex-workspaces stats work --days 14
195
195
  ```bash
196
196
  codex-workspaces config set experimental_private_api.enabled true
197
197
  codex-workspaces config set experimental_private_api.quota_enabled true
198
- codex-workspaces config set experimental_private_api.refresh_enabled true
199
198
  ```
200
199
 
200
+ 当前 endpoint 配置字段是:
201
+
202
+ - `experimental_private_api.base_url` 默认 `https://chatgpt.com`
203
+ - `experimental_private_api.quota_endpoint` 默认 `/backend-api/wham/usage`
204
+ - `experimental_private_api.account_endpoint` 默认空字符串
205
+
201
206
  查询当前账号额度:
202
207
 
203
208
  ```bash
@@ -226,6 +231,8 @@ codex-workspaces accounts refresh acct_work
226
231
  codex-workspaces accounts refresh --all --json
227
232
  ```
228
233
 
234
+ refresh 当前仍默认关闭。当前 `account_endpoint` 不是 Codex responses API;除非 provider 实现了 POST responses 流程,否则不要把它指向 responses endpoint。
235
+
229
236
  实时额度能力使用显式实验配置、请求超时、串行账号遍历和本地 TTL 缓存,缓存目录为 `~/.codex-workspaces/cache/quota/`。缓存只保存额度摘要和 auth hash,不保存 token、cookie、authorization header 或原始 `auth.json`。
230
237
 
231
238
  `stats` 和 `quota` 不同:`stats` 是本地 SQLite 历史统计,`quota` 是实时远端查询。额度查询失败不会影响本地 workspace/account 切换。
@@ -246,6 +253,7 @@ codex-workspaces accounts restore-default
246
253
  ```bash
247
254
  codex-workspaces accounts note acct_research "实验室账号"
248
255
  codex-workspaces accounts info acct_research
256
+ codex-workspaces accounts info "$(codex-workspaces accounts current --id)"
249
257
  codex-workspaces accounts rename acct_research acct_lab
250
258
  codex-workspaces accounts delete acct_lab --force
251
259
  ```
@@ -88,9 +88,9 @@ permissions:
88
88
  1. 更新版本号和 `CHANGELOG.md`。
89
89
  2. 本地执行测试和构建检查。
90
90
  3. 合并到 `main`。
91
- 4. 创建 Git tag,例如 `v0.3.3`,触发 `Publish to TestPyPI`。
91
+ 4. 创建 Git tag,例如 `v0.3.4`,触发 `Publish to TestPyPI`。
92
92
  5. 确认 TestPyPI 上传和安装正常。
93
- 6. 从同一个提交创建并推送正式发布分支,例如 `release/v0.3.3`,触发 `Publish to PyPI`。
93
+ 6. 从同一个提交创建并推送正式发布分支,例如 `release/v0.3.4`,触发 `Publish to PyPI`。
94
94
  7. 如果 `pypi` Environment 配置了 Required reviewers,在 GitHub Actions 里批准部署。
95
95
  8. 在 PyPI 页面确认 wheel、sdist 和 README 渲染正常。
96
96
 
@@ -130,7 +130,7 @@ CODEX_WORKSPACES_LINK="$tmp_home/.codex" \
130
130
  CODEX_WORKSPACES_ROOT="$tmp_home/.codex-workspaces" \
131
131
  codex-workspaces config set experimental_private_api.quota_enabled true
132
132
 
133
- # 需要配置实际 provider endpoint 后才会真实请求;无 endpoint 时会安全失败。
133
+ # 默认内置 chatgpt.com WHAM quota endpoint;开启后会真实请求实验性 private API。
134
134
  # CODEX_WORKSPACES_LINK="$tmp_home/.codex" \
135
135
  # CODEX_WORKSPACES_ROOT="$tmp_home/.codex-workspaces" \
136
136
  # codex-workspaces quota --json
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "codex-workspaces"
7
- version = "0.3.3"
7
+ version = "0.3.4"
8
8
  description = "Cross-platform Codex workspace switcher with a preserved macOS shell workflow."
9
9
  readme = "README.MD"
10
10
  requires-python = ">=3.9"
@@ -1,3 +1,3 @@
1
1
  """Codex workspace switching utilities."""
2
2
 
3
- __version__ = "0.3.3"
3
+ __version__ = "0.3.4"
@@ -50,8 +50,7 @@ def run(argv: Sequence[str], manager: WorkspaceManager) -> int:
50
50
  no_cache = True
51
51
  else:
52
52
  manager.fail(f"未知参数: {arg}", f"Unknown option: {arg}")
53
- manager.show_quota(json_output=json_output, no_cache=no_cache)
54
- return 0
53
+ return manager.show_quota(json_output=json_output, no_cache=no_cache)
55
54
  if command == "accounts":
56
55
  return run_accounts(args, manager)
57
56
  if command == "stats":
@@ -262,9 +261,18 @@ def run_accounts(args: Sequence[str], manager: WorkspaceManager) -> int:
262
261
  manager.accounts_list(all_with_quota=all_with_quota, no_cache=no_cache, json_output=json_output, verbose=verbose)
263
262
  return 0
264
263
  if command in {"current", "whoami"}:
265
- if rest:
266
- manager.fail(f"未知参数: {rest[0]}", f"Unknown option: {rest[0]}")
267
- manager.accounts_current()
264
+ id_only = False
265
+ json_output = False
266
+ for arg in rest:
267
+ if arg == "--id":
268
+ id_only = True
269
+ elif arg == "--json":
270
+ json_output = True
271
+ else:
272
+ manager.fail(f"未知参数: {arg}", f"Unknown option: {arg}")
273
+ if id_only and json_output:
274
+ manager.fail("--id 和 --json 不能同时使用", "--id and --json cannot be used together")
275
+ manager.accounts_current(id_only=id_only, json_output=json_output)
268
276
  return 0
269
277
  if command == "info":
270
278
  if len(rest) != 1:
@@ -319,8 +327,7 @@ def run_accounts(args: Sequence[str], manager: WorkspaceManager) -> int:
319
327
  account = arg
320
328
  else:
321
329
  manager.fail(f"未知参数: {arg}", f"Unknown option: {arg}")
322
- manager.accounts_quota(account or "", json_output=json_output, no_cache=no_cache)
323
- return 0
330
+ return manager.accounts_quota(account or "", json_output=json_output, no_cache=no_cache)
324
331
  if command == "export":
325
332
  if not rest:
326
333
  manager.fail(
@@ -22,7 +22,16 @@ from .errors import CodexWorkspacesError
22
22
  from .platforms import SystemPlatform
23
23
  from .private_api import AccountRemoteInfo, AuthMaterial, ConfiguredHttpPrivateApiProvider, PrivateApiProvider, QuotaInfo
24
24
  from .private_api.auth import extract_auth_material
25
- from .private_api.errors import PrivateApiDisabledError, PrivateApiError
25
+ from .private_api.errors import (
26
+ PrivateApiAuthError,
27
+ PrivateApiDisabledError,
28
+ PrivateApiError,
29
+ PrivateApiForbiddenError,
30
+ PrivateApiNetworkError,
31
+ PrivateApiRateLimitedError,
32
+ PrivateApiUnsupportedResponseError,
33
+ redact_sensitive_text,
34
+ )
26
35
  from .store import AccountMeta, WorkspaceMeta, WorkspaceStore, auth_hash, chmod_best_effort, copy_auth, iso_now, read_json, write_json_atomic
27
36
  from .stats import ModelUsage, StatsBundle, StatsError, WorkspaceStats, combine_workspace_stats, compute_workspace_stats
28
37
 
@@ -419,6 +428,11 @@ class WorkspaceManager:
419
428
  defaults = self.default_private_api_config()
420
429
  for key, value in defaults.items():
421
430
  private.setdefault(key, value)
431
+ if private.get("provider") == "codex":
432
+ if not private.get("base_url"):
433
+ private["base_url"] = defaults["base_url"]
434
+ if not private.get("quota_endpoint"):
435
+ private["quota_endpoint"] = defaults["quota_endpoint"]
422
436
  return data
423
437
 
424
438
  def write_tool_config(self, data: dict) -> None:
@@ -430,8 +444,8 @@ class WorkspaceManager:
430
444
  "quota_enabled": False,
431
445
  "refresh_enabled": False,
432
446
  "provider": "codex",
433
- "base_url": "",
434
- "quota_endpoint": "",
447
+ "base_url": "https://chatgpt.com",
448
+ "quota_endpoint": "/backend-api/wham/usage",
435
449
  "account_endpoint": "",
436
450
  "timeout_seconds": 10,
437
451
  "rate_limit_per_minute": 20,
@@ -528,6 +542,116 @@ class WorkspaceManager:
528
542
  user_agent=f"codex-workspaces/{self.tool_version()} experimental-private-api",
529
543
  )
530
544
 
545
+ def ensure_quota_endpoint_configured(self, settings: dict) -> None:
546
+ if self.private_api_provider is None and not settings.get("quota_endpoint"):
547
+ raise PrivateApiUnsupportedResponseError("quota endpoint is not configured")
548
+
549
+ def private_api_error_payload(self, exc: Exception) -> dict[str, object]:
550
+ if isinstance(exc, PrivateApiDisabledError):
551
+ error_type = "disabled"
552
+ elif isinstance(exc, PrivateApiAuthError):
553
+ error_type = "auth_error"
554
+ elif isinstance(exc, PrivateApiRateLimitedError):
555
+ error_type = "rate_limited"
556
+ elif isinstance(exc, PrivateApiForbiddenError):
557
+ error_type = "forbidden"
558
+ elif isinstance(exc, PrivateApiUnsupportedResponseError):
559
+ error_type = "unsupported_response"
560
+ elif isinstance(exc, PrivateApiNetworkError):
561
+ error_type = "network_error"
562
+ elif isinstance(exc, CodexWorkspacesError) and (
563
+ "experimental private API features are disabled" in str(exc) or "private API 功能未开启" in str(exc)
564
+ ):
565
+ error_type = "disabled"
566
+ else:
567
+ error_type = "internal_error"
568
+ return {"type": error_type, "message": redact_sensitive_text(str(exc))}
569
+
570
+ def quota_list_status_from_error(self, exc: Exception) -> str:
571
+ payload = self.private_api_error_payload(exc)
572
+ error_type = str(payload["type"])
573
+ message = str(payload["message"])
574
+ if error_type == "unsupported_response" and "quota endpoint is not configured" in message:
575
+ return "not-configured"
576
+ return {
577
+ "auth_error": "auth-error",
578
+ "rate_limited": "rate-limited",
579
+ "network_error": "network-error",
580
+ "unsupported_response": "error",
581
+ "internal_error": "error",
582
+ }.get(error_type, error_type.replace("_", "-"))
583
+
584
+ def format_private_api_error(self, exc: Exception) -> str:
585
+ payload = self.private_api_error_payload(exc)
586
+ error_type = str(payload["type"])
587
+ message = str(payload["message"])
588
+ if error_type == "disabled":
589
+ return "\n".join(
590
+ [
591
+ "ERROR: experimental private API features are disabled.",
592
+ "",
593
+ "Enable explicitly:",
594
+ " codex-workspaces config set experimental_private_api.enabled true",
595
+ " codex-workspaces config set experimental_private_api.quota_enabled true",
596
+ ]
597
+ )
598
+ if error_type == "unsupported_response" and "quota endpoint is not configured" in message:
599
+ return "\n".join(
600
+ [
601
+ "ERROR: realtime quota is not configured.",
602
+ "",
603
+ message,
604
+ "",
605
+ "Hint:",
606
+ " Configure experimental_private_api.quota_endpoint, or disable quota:",
607
+ " codex-workspaces config set experimental_private_api.quota_enabled false",
608
+ ]
609
+ )
610
+ titles = {
611
+ "auth_error": "ERROR: realtime quota authentication failed.",
612
+ "forbidden": "ERROR: realtime quota access was forbidden.",
613
+ "rate_limited": "ERROR: realtime quota is rate limited.",
614
+ "network_error": "ERROR: realtime quota request failed.",
615
+ "unsupported_response": "ERROR: realtime quota response is unsupported.",
616
+ }
617
+ return "\n".join(
618
+ [
619
+ titles.get(error_type, "ERROR: realtime quota failed."),
620
+ "",
621
+ message,
622
+ "",
623
+ "Hint:",
624
+ " Check experimental_private_api settings, your account auth, or try again later.",
625
+ ]
626
+ )
627
+
628
+ def render_private_api_error(
629
+ self,
630
+ exc: Exception,
631
+ *,
632
+ json_output: bool,
633
+ account_id: Optional[str],
634
+ workspace: Optional[str],
635
+ ) -> int:
636
+ payload = self.private_api_error_payload(exc)
637
+ if json_output:
638
+ self.info(
639
+ json.dumps(
640
+ {
641
+ "status": "error",
642
+ "account": account_id,
643
+ "workspace": workspace,
644
+ "error": payload,
645
+ },
646
+ ensure_ascii=False,
647
+ indent=2,
648
+ sort_keys=True,
649
+ )
650
+ )
651
+ else:
652
+ self.info(self.format_private_api_error(exc))
653
+ return 2
654
+
531
655
  def show_stats(
532
656
  self,
533
657
  name: Optional[str] = None,
@@ -1539,12 +1663,29 @@ class WorkspaceManager:
1539
1663
  f"{(quota.plan or account.plan or '-'): <7} {self.percent_text(quota.used_percent):<8} {reset_at:<17} {status:<10} {quota.source}"
1540
1664
  )
1541
1665
 
1542
- def accounts_current(self) -> None:
1666
+ def accounts_current(self, *, id_only: bool = False, json_output: bool = False) -> None:
1543
1667
  current = self.current_target()
1544
1668
  if current.kind != "target" or current.path is None:
1545
1669
  self.fail("当前工作区不存在。", "Current workspace does not exist.")
1546
1670
  name = self.current_name(current.path) or "current"
1547
1671
  meta = self.store.ensure_workspace_meta(name, current.path)
1672
+ if id_only:
1673
+ self.info(meta.active_account_id or "")
1674
+ return
1675
+ if json_output:
1676
+ self.info(
1677
+ json.dumps(
1678
+ {
1679
+ "workspace": name,
1680
+ "active_account_id": meta.active_account_id,
1681
+ "default_account_id": meta.default_account_id,
1682
+ },
1683
+ ensure_ascii=False,
1684
+ indent=2,
1685
+ sort_keys=True,
1686
+ )
1687
+ )
1688
+ return
1548
1689
  self.info(f"{name}: active={meta.active_account_id or '-'} default={meta.default_account_id or '-'}")
1549
1690
 
1550
1691
  def accounts_info(self, account: str) -> None:
@@ -1658,6 +1799,7 @@ class WorkspaceManager:
1658
1799
 
1659
1800
  def get_account_quota(self, account_id: str, *, no_cache: bool = False) -> QuotaInfo:
1660
1801
  settings = self.ensure_private_api_enabled("quota")
1802
+ self.ensure_quota_endpoint_configured(settings)
1661
1803
  auth_material = self.account_auth_material(account_id)
1662
1804
  ttl = int(settings.get("cache_ttl_seconds") or 300)
1663
1805
  if not no_cache:
@@ -1692,22 +1834,39 @@ class WorkspaceManager:
1692
1834
  return QuotaInfo(status="disabled", error=str(exc), source="disabled")
1693
1835
  return QuotaInfo(status="no-auth", error=str(exc), source="local")
1694
1836
  except PrivateApiError as exc:
1695
- return QuotaInfo(status="error", error=str(exc) if verbose else None, source="private-api")
1837
+ return QuotaInfo(status=self.quota_list_status_from_error(exc), error=str(exc) if verbose else None, source="private-api")
1838
+ except Exception as exc:
1839
+ return QuotaInfo(
1840
+ status=self.quota_list_status_from_error(exc),
1841
+ error=redact_sensitive_text(str(exc)) if verbose else None,
1842
+ source="private-api",
1843
+ )
1696
1844
 
1697
- def show_quota(self, *, json_output: bool = False, no_cache: bool = False) -> None:
1698
- workspace, _, meta = self.current_workspace_account()
1699
- account_id = meta.active_account_id or meta.default_account_id
1700
- assert account_id is not None
1701
- quota = self.get_account_quota(account_id, no_cache=no_cache)
1702
- account_meta = self.store.read_account_meta(account_id) if self.store.account_meta_path(account_id).is_file() else None
1703
- self.render_quota(workspace, account_id, quota, account_meta, json_output=json_output)
1845
+ def show_quota(self, *, json_output: bool = False, no_cache: bool = False) -> int:
1846
+ workspace = None
1847
+ account_id = None
1848
+ try:
1849
+ workspace, _, meta = self.current_workspace_account()
1850
+ account_id = meta.active_account_id or meta.default_account_id
1851
+ assert account_id is not None
1852
+ quota = self.get_account_quota(account_id, no_cache=no_cache)
1853
+ account_meta = self.store.read_account_meta(account_id) if self.store.account_meta_path(account_id).is_file() else None
1854
+ self.render_quota(workspace, account_id, quota, account_meta, json_output=json_output)
1855
+ return 0
1856
+ except Exception as exc:
1857
+ return self.render_private_api_error(exc, json_output=json_output, account_id=account_id, workspace=workspace)
1704
1858
 
1705
- def accounts_quota(self, account: str, *, json_output: bool = False, no_cache: bool = False) -> None:
1706
- account_id = self.account_id_from_input(account)
1707
- if not self.store.account_meta_path(account_id).is_file():
1708
- self.fail(f"账号不存在: {account_id}", f"Account not found: {account_id}")
1709
- quota = self.get_account_quota(account_id, no_cache=no_cache)
1710
- self.render_quota(None, account_id, quota, self.store.read_account_meta(account_id), json_output=json_output)
1859
+ def accounts_quota(self, account: str, *, json_output: bool = False, no_cache: bool = False) -> int:
1860
+ account_id = None
1861
+ try:
1862
+ account_id = self.account_id_from_input(account)
1863
+ if not self.store.account_meta_path(account_id).is_file():
1864
+ self.fail(f"账号不存在: {account_id}", f"Account not found: {account_id}")
1865
+ quota = self.get_account_quota(account_id, no_cache=no_cache)
1866
+ self.render_quota(None, account_id, quota, self.store.read_account_meta(account_id), json_output=json_output)
1867
+ return 0
1868
+ except Exception as exc:
1869
+ return self.render_private_api_error(exc, json_output=json_output, account_id=account_id, workspace=None)
1711
1870
 
1712
1871
  def render_quota(self, workspace: Optional[str], account_id: str, quota: QuotaInfo, account_meta: Optional[AccountMeta], *, json_output: bool) -> None:
1713
1872
  payload = {
@@ -2806,7 +2965,7 @@ def usage(lang: str) -> str:
2806
2965
  迁移旧 ~/.codex-<工作区名> 目录,并可导入旧 ~/.codex-accounts。
2807
2966
 
2808
2967
  codex-workspaces accounts list
2809
- codex-workspaces accounts current
2968
+ codex-workspaces accounts current [--id|--json]
2810
2969
  codex-workspaces accounts info <账号>
2811
2970
  codex-workspaces accounts init <账号>
2812
2971
  codex-workspaces accounts save <账号>
@@ -2910,7 +3069,7 @@ Usage:
2910
3069
  Migrate legacy ~/.codex-<workspace> directories and optionally import old ~/.codex-accounts.
2911
3070
 
2912
3071
  codex-workspaces accounts list
2913
- codex-workspaces accounts current
3072
+ codex-workspaces accounts current [--id|--json]
2914
3073
  codex-workspaces accounts info <account>
2915
3074
  codex-workspaces accounts init <account>
2916
3075
  codex-workspaces accounts save <account>
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from ..store import auth_hash
8
+ from .errors import PrivateApiAuthError
9
+ from .models import AuthMaterial
10
+
11
+
12
+ def extract_auth_material(account_id: str, auth_path: Path) -> AuthMaterial:
13
+ raw_hash = auth_hash(auth_path)
14
+ try:
15
+ data = json.loads(auth_path.read_text(encoding="utf-8"))
16
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
17
+ raise PrivateApiAuthError("auth.json is not readable JSON") from exc
18
+ if not isinstance(data, dict):
19
+ raise PrivateApiAuthError("auth.json is not a JSON object")
20
+
21
+ auth_mode = string_value(data.get("auth_mode"))
22
+ if auth_mode != "chatgpt":
23
+ raise PrivateApiAuthError("unsupported auth_mode; run codex login or an official codex command to refresh auth")
24
+
25
+ tokens = data.get("tokens")
26
+ if not isinstance(tokens, dict):
27
+ raise PrivateApiAuthError("missing tokens in auth.json; run codex login or an official codex command to refresh auth")
28
+
29
+ access_token = string_value(tokens.get("access_token"))
30
+ refresh_token = string_value(tokens.get("refresh_token"))
31
+ openai_account_id = string_value(tokens.get("account_id")) or string_value(data.get("account_id"))
32
+ if not access_token:
33
+ raise PrivateApiAuthError("missing tokens.access_token; run codex login or an official codex command to refresh auth")
34
+
35
+ return AuthMaterial(
36
+ account_id=account_id,
37
+ auth_path=auth_path,
38
+ access_token=access_token,
39
+ refresh_token=refresh_token,
40
+ raw_auth_hash=raw_hash,
41
+ openai_account_id=openai_account_id,
42
+ )
43
+
44
+
45
+ def string_value(value: Any) -> str | None:
46
+ if isinstance(value, str) and value.strip():
47
+ return value.strip()
48
+ return None
@@ -1,10 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import base64
3
4
  import json
4
5
  import socket
5
6
  import time
6
7
  import urllib.error
7
8
  import urllib.request
9
+ from datetime import datetime, timedelta, timezone
8
10
  from typing import Protocol
9
11
  from urllib.parse import urljoin
10
12
 
@@ -67,10 +69,14 @@ class ConfiguredHttpPrivateApiProvider:
67
69
  def request_json(self, endpoint: str, auth: AuthMaterial) -> dict:
68
70
  if not auth.access_token:
69
71
  raise PrivateApiAuthError("missing access token")
72
+ if not auth.openai_account_id:
73
+ raise PrivateApiAuthError("missing OpenAI account id in auth.json tokens.account_id")
74
+ ensure_access_token_fresh(auth.access_token)
70
75
  request = urllib.request.Request(urljoin(self.base_url.rstrip("/") + "/", endpoint.lstrip("/")))
71
76
  request.add_header("User-Agent", self.user_agent)
72
77
  request.add_header("Accept", "application/json")
73
78
  request.add_header("Authorization", "Bearer " + auth.access_token)
79
+ request.add_header("OpenAI-Account-Id", auth.openai_account_id)
74
80
  attempts = 0
75
81
  while True:
76
82
  try:
@@ -82,7 +88,7 @@ class ConfiguredHttpPrivateApiProvider:
82
88
  return data
83
89
  except urllib.error.HTTPError as exc:
84
90
  if exc.code == 401:
85
- raise PrivateApiAuthError("unauthorized") from exc
91
+ raise PrivateApiAuthError("unauthorized; run codex login or an official codex command to refresh auth") from exc
86
92
  if exc.code == 403:
87
93
  raise PrivateApiForbiddenError("forbidden") from exc
88
94
  if exc.code == 429 and attempts < 1:
@@ -100,7 +106,53 @@ class ConfiguredHttpPrivateApiProvider:
100
106
  raise PrivateApiUnsupportedResponseError("response is not valid JSON") from exc
101
107
 
102
108
 
109
+ def ensure_access_token_fresh(access_token: str) -> None:
110
+ expires_at = jwt_exp(access_token)
111
+ if expires_at is None:
112
+ return
113
+ if expires_at <= time.time() + 60:
114
+ raise PrivateApiAuthError("access token expired; run codex login or an official codex command to refresh auth")
115
+
116
+
117
+ def jwt_exp(access_token: str) -> float | None:
118
+ parts = access_token.split(".")
119
+ if len(parts) < 2:
120
+ return None
121
+ payload = parts[1]
122
+ payload += "=" * (-len(payload) % 4)
123
+ try:
124
+ data = json.loads(base64.urlsafe_b64decode(payload.encode("ascii")).decode("utf-8"))
125
+ except (ValueError, UnicodeDecodeError, json.JSONDecodeError):
126
+ return None
127
+ exp = data.get("exp")
128
+ if isinstance(exp, (int, float)):
129
+ return float(exp)
130
+ return None
131
+
132
+
103
133
  def quota_from_response(data: dict) -> QuotaInfo:
134
+ rate_limit = data.get("rate_limit")
135
+ if isinstance(rate_limit, dict) and isinstance(rate_limit.get("primary_window"), dict):
136
+ primary_window = rate_limit["primary_window"]
137
+ used_percent = first_number(primary_window, "used_percent", "usedPercent", "used_pct", "usage_percent")
138
+ remaining_percent = max(0.0, 100.0 - used_percent) if used_percent is not None else None
139
+ limit_reached = bool(rate_limit.get("limit_reached"))
140
+ allowed = rate_limit.get("allowed")
141
+ status = "ok"
142
+ if limit_reached:
143
+ status = "limit_reached"
144
+ elif allowed is False:
145
+ status = "not_allowed"
146
+ return QuotaInfo(
147
+ status=status,
148
+ used_percent=used_percent,
149
+ remaining_percent=remaining_percent,
150
+ reset_at=normalize_reset_at(primary_window.get("reset_at"))
151
+ or reset_after_to_iso(first_number(primary_window, "reset_after_seconds", "resetAfterSeconds")),
152
+ window_duration_mins=seconds_to_minutes(first_number(primary_window, "limit_window_seconds", "limitWindowSeconds")),
153
+ plan=wham_plan(data.get("credits")),
154
+ )
155
+
104
156
  quota_data = data.get("quota") if isinstance(data.get("quota"), dict) else data
105
157
  used_percent = first_number(quota_data, "used_percent", "usedPercent", "used_pct", "usage_percent")
106
158
  remaining_percent = first_number(quota_data, "remaining_percent", "remainingPercent", "remaining_pct")
@@ -122,6 +174,39 @@ def quota_from_response(data: dict) -> QuotaInfo:
122
174
  )
123
175
 
124
176
 
177
+ def normalize_reset_at(value) -> str | None:
178
+ if isinstance(value, str) and value:
179
+ return value
180
+ if isinstance(value, (int, float)):
181
+ try:
182
+ return datetime.fromtimestamp(float(value), tz=timezone.utc).isoformat()
183
+ except (OSError, OverflowError, ValueError):
184
+ return str(value)
185
+ return None
186
+
187
+
188
+ def seconds_to_minutes(value: float | None) -> int | None:
189
+ if value is None:
190
+ return None
191
+ return int(value / 60)
192
+
193
+
194
+ def reset_after_to_iso(value: float | None) -> str | None:
195
+ if value is None:
196
+ return None
197
+ return (datetime.now(timezone.utc) + timedelta(seconds=value)).isoformat()
198
+
199
+
200
+ def wham_plan(credits) -> str | None:
201
+ if not isinstance(credits, dict):
202
+ return None
203
+ if credits.get("unlimited") is True:
204
+ return "unlimited"
205
+ if credits.get("balance") is not None:
206
+ return "credits"
207
+ return None
208
+
209
+
125
210
  def pick(data: dict, *keys: str) -> str | None:
126
211
  for key in keys:
127
212
  value = data.get(key)