tsugite-codex-cli 0.23.1__tar.gz → 0.24.0__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.
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tsugite-codex-cli
3
- Version: 0.23.1
3
+ Version: 0.24.0
4
4
  Summary: Tsugite plugin: ChatGPT subscription auth via Codex CLI token store
5
5
  Author: Justyn Shull
6
6
  Requires-Python: >=3.11
7
- Requires-Dist: tsugite-cli==0.23.1
7
+ Requires-Dist: tsugite-cli==0.24.0
@@ -1,16 +1,19 @@
1
1
  [project]
2
2
  name = "tsugite-codex-cli"
3
- version = "0.23.1"
3
+ version = "0.24.0"
4
4
  description = "Tsugite plugin: ChatGPT subscription auth via Codex CLI token store"
5
5
  authors = [{ name = "Justyn Shull" }]
6
6
  requires-python = ">=3.11"
7
7
  dependencies = [
8
- "tsugite-cli==0.23.1",
8
+ "tsugite-cli==0.24.0",
9
9
  ]
10
10
 
11
11
  [project.entry-points."tsugite.providers"]
12
12
  codex_cli = "tsugite_codex_cli:create_provider"
13
13
 
14
+ [project.entry-points."tsugite.usage_providers"]
15
+ codex_cli = "tsugite_codex_cli.usage"
16
+
14
17
  [build-system]
15
18
  requires = ["hatchling"]
16
19
  build-backend = "hatchling.build"
@@ -64,9 +64,9 @@ class CodexAuthStore:
64
64
 
65
65
  async def get_access_token(self) -> tuple[str, str]:
66
66
  """Return (access_token, account_id), refreshing if near expiry."""
67
- return await asyncio.to_thread(self._sync_get_or_refresh)
67
+ return await asyncio.to_thread(self.sync_get_or_refresh)
68
68
 
69
- def _sync_get_or_refresh(self) -> tuple[str, str]:
69
+ def sync_get_or_refresh(self) -> tuple[str, str]:
70
70
  if not self._auth_path.exists():
71
71
  raise CodexAuthError(f"No Codex auth file at {self._auth_path}. Run `codex login` first.")
72
72
 
@@ -18,15 +18,9 @@ from tsugite_codex_cli.auth import CodexAuthError, CodexAuthStore
18
18
  logger = logging.getLogger(__name__)
19
19
 
20
20
  API_BASE = "https://chatgpt.com/backend-api/codex"
21
- _REASONING_LEVELS = ["low", "medium", "high"]
22
- # gpt-5.5 added xhigh; older codex models don't accept it.
23
- _REASONING_LEVELS_V5_5 = ["low", "medium", "high", "xhigh"]
24
- # gpt-5.6 added max; its sol/terra tiers and gpt-6-astra also accept ultra (delegating).
25
- _REASONING_LEVELS_V5_6 = [*_REASONING_LEVELS_V5_5, "max"]
26
- _REASONING_LEVELS_V5_6_AGENTIC = [*_REASONING_LEVELS_V5_6, "ultra"]
27
21
 
28
22
 
29
- def _codex_model_info(max_input_tokens: int, effort_levels: list[str] = _REASONING_LEVELS) -> ModelInfo:
23
+ def _codex_model_info(max_input_tokens: int, effort_levels: list[str]) -> ModelInfo:
30
24
  # Subscription billing means per-token cost is not surfaced here.
31
25
  return ModelInfo(
32
26
  max_input_tokens=max_input_tokens,
@@ -39,18 +33,22 @@ def _codex_model_info(max_input_tokens: int, effort_levels: list[str] = _REASONI
39
33
  )
40
34
 
41
35
 
42
- # Context windows are the `context_window` the backend reports for the
43
- # subscription path, which is smaller than the same model's API tier. Not
44
- # `max_context_window`, which some slugs report as 1M.
36
+ # --- BEGIN GENERATED MODELS (codex_cli) --- #
37
+ # Auto-generated by scripts/update_model_registry.py, do not edit manually
38
+ # fmt: off
45
39
  _CODEX_CLI_MODELS: dict[str, ModelInfo] = {
46
- "codex_cli/gpt-5.6-sol": _codex_model_info(272_000, effort_levels=_REASONING_LEVELS_V5_6_AGENTIC),
47
- "codex_cli/gpt-5.6-terra": _codex_model_info(272_000, effort_levels=_REASONING_LEVELS_V5_6_AGENTIC),
48
- "codex_cli/gpt-5.6-luna": _codex_model_info(272_000, effort_levels=_REASONING_LEVELS_V5_6),
49
- "codex_cli/gpt-5.5": _codex_model_info(272_000, effort_levels=_REASONING_LEVELS_V5_5),
50
- "codex_cli/gpt-6-astra": _codex_model_info(272_000, effort_levels=_REASONING_LEVELS_V5_6_AGENTIC),
40
+ "codex_cli/gpt-5.5": _codex_model_info(272_000, ["low", "medium", "high", "xhigh"]),
41
+ "codex_cli/gpt-5.6-luna": _codex_model_info(272_000, ["low", "medium", "high", "xhigh", "max"]),
42
+ "codex_cli/gpt-5.6-sol": _codex_model_info(272_000, ["low", "medium", "high", "xhigh", "max", "ultra"]),
43
+ "codex_cli/gpt-5.6-terra": _codex_model_info(272_000, ["low", "medium", "high", "xhigh", "max", "ultra"]),
44
+ "codex_cli/gpt-6-astra": _codex_model_info(272_000, ["low", "medium", "high", "xhigh", "max", "ultra"]),
45
+ "codex_cli/gpt-6-luna": _codex_model_info(272_000, ["low", "medium", "high", "xhigh", "max"]),
46
+ "codex_cli/gpt-6-sol": _codex_model_info(272_000, ["low", "medium", "high", "xhigh", "max", "ultra"]),
51
47
  }
48
+ # fmt: on
49
+ # --- END GENERATED MODELS (codex_cli) --- #
52
50
 
53
- # Used when /models is unreachable; kept in sync with the registry above.
51
+ # Used when /models is unreachable.
54
52
  _FALLBACK_MODELS = [k.split("/", 1)[1] for k in _CODEX_CLI_MODELS]
55
53
 
56
54
 
@@ -335,22 +333,25 @@ class CodexCliProvider:
335
333
  # on parallel sessions. The client is released when the process exits.
336
334
  pass
337
335
 
336
+ async def fetch_models(self) -> list[dict]:
337
+ access_token, account_id = await self._auth.get_access_token()
338
+ client = self._get_client()
339
+ resp = await client.get(
340
+ f"{API_BASE}/models?client_version=1.0.0",
341
+ headers=self._build_headers(access_token, account_id),
342
+ )
343
+ resp.raise_for_status()
344
+ # The Codex backend answers {"models":[{"slug":...}]}, not the OpenAI
345
+ # {"data":[{"id":...}]} shape. `visibility: hide` marks special-purpose
346
+ # entries (auto-review, watermark variants) the CLI keeps out of its
347
+ # own picker, so they stay out of ours too.
348
+ return [m for m in resp.json()["models"] if isinstance(m, dict) and m.get("visibility") != "hide"]
349
+
338
350
  async def list_models(self) -> list[str]:
339
351
  # Auth lookup is inside the try: model-picker UIs should always get a
340
352
  # usable list even before `codex login` has been run.
341
353
  try:
342
- access_token, account_id = await self._auth.get_access_token()
343
- client = self._get_client()
344
- resp = await client.get(
345
- f"{API_BASE}/models?client_version=1.0.0",
346
- headers=self._build_headers(access_token, account_id),
347
- )
348
- resp.raise_for_status()
349
- # The Codex backend answers {"models":[{"slug":...}]}, not the OpenAI
350
- # {"data":[{"id":...}]} shape. `visibility: hide` marks special-purpose
351
- # entries (auto-review, watermark variants) the CLI keeps out of its
352
- # own picker, so they stay out of ours too.
353
- return [m["slug"] for m in resp.json()["models"] if isinstance(m, dict) and m.get("visibility") != "hide"]
354
+ return [m["slug"] for m in await self.fetch_models()]
354
355
  except (httpx.HTTPError, KeyError, ValueError, CodexAuthError):
355
356
  return list(_FALLBACK_MODELS)
356
357
 
@@ -0,0 +1,81 @@
1
+ """ChatGPT subscription limits for Codex as a tsugite quota source.
2
+
3
+ Read from ``rate_limit`` in the ChatGPT usage endpoint, with the Codex CLI's
4
+ OAuth token. Each window is labeled by its duration, so a window length
5
+ tsugite has never seen still shows up.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import time
12
+ from datetime import datetime, timezone
13
+
14
+ import httpx
15
+
16
+ from tsugite.usage.quota import QuotaReport, QuotaWindow, register_quota_source
17
+ from tsugite.user_agent import set_user_agent_header
18
+ from tsugite_codex_cli.auth import CodexAuthStore
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ PROVIDER = "codex_cli"
23
+ LABEL = "Codex"
24
+ USAGE_URL = "https://chatgpt.com/backend-api/wham/usage"
25
+ LIVE_TTL_S = 60
26
+
27
+ _last_live: tuple[float, dict] | None = None
28
+
29
+
30
+ def _live_rate_limit() -> dict:
31
+ global _last_live
32
+ if _last_live and time.monotonic() - _last_live[0] < LIVE_TTL_S:
33
+ return _last_live[1]
34
+ access_token, account_id = CodexAuthStore().sync_get_or_refresh()
35
+ headers = {"Authorization": f"Bearer {access_token}", "ChatGPT-Account-ID": account_id}
36
+ set_user_agent_header(headers)
37
+ response = httpx.get(USAGE_URL, headers=headers, timeout=10)
38
+ response.raise_for_status()
39
+ rate_limit = response.json()["rate_limit"]
40
+ _last_live = (time.monotonic(), rate_limit)
41
+ return rate_limit
42
+
43
+
44
+ def _label(seconds: int) -> str:
45
+ if seconds == 7 * 24 * 3600:
46
+ return "week"
47
+ if seconds % 3600:
48
+ return f"{seconds // 60}m"
49
+ return f"{seconds // 3600}h"
50
+
51
+
52
+ def _error(message: str) -> QuotaReport:
53
+ return QuotaReport(provider=PROVIDER, label=LABEL, error=message)
54
+
55
+
56
+ def fetch() -> QuotaReport:
57
+ try:
58
+ rate_limit = _live_rate_limit()
59
+ except Exception as e:
60
+ # A failed token refresh can quote the auth server's response body.
61
+ logger.warning("Codex usage unavailable: %s", type(e).__name__)
62
+ return _error("Codex's usage limits could not be fetched")
63
+
64
+ try:
65
+ windows = tuple(
66
+ QuotaWindow(
67
+ key=key,
68
+ label=_label(window["limit_window_seconds"]),
69
+ used_pct=float(window["used_percent"]),
70
+ resets_at=datetime.fromtimestamp(window["reset_at"], timezone.utc).isoformat(),
71
+ )
72
+ for key in ("primary", "secondary")
73
+ if (window := rate_limit.get(f"{key}_window"))
74
+ )
75
+ except Exception:
76
+ return _error("Codex's usage limits are not in a shape tsugite understands")
77
+
78
+ return QuotaReport(provider=PROVIDER, label=LABEL, windows=windows)
79
+
80
+
81
+ register_quota_source(PROVIDER, LABEL, fetch)