quse 0.0.1__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.
- quse-0.0.1/PKG-INFO +36 -0
- quse-0.0.1/README.md +26 -0
- quse-0.0.1/pyproject.toml +30 -0
- quse-0.0.1/quse/__init__.py +8 -0
- quse-0.0.1/quse/_shared.py +85 -0
- quse-0.0.1/quse/claude_quota.py +170 -0
- quse-0.0.1/quse/cli.py +52 -0
- quse-0.0.1/quse/codex_quota.py +175 -0
- quse-0.0.1/quse/copilot_quota.py +119 -0
- quse-0.0.1/quse/usage.py +223 -0
- quse-0.0.1/quse/zai_quota.py +124 -0
- quse-0.0.1/tests/test_cli.py +45 -0
- quse-0.0.1/tests/test_codex_quota.py +197 -0
- quse-0.0.1/tests/test_quota_parsers.py +181 -0
- quse-0.0.1/uv.lock +734 -0
quse-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: quse
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Quota and usage checks for coding-agent CLIs.
|
|
5
|
+
Author: Alexey Grigorev
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: click>=8.2.0
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# quse
|
|
12
|
+
|
|
13
|
+
Quota and usage checks for coding-agent CLIs.
|
|
14
|
+
|
|
15
|
+
`quse` reports normalized usage for providers used by tools such as Codex,
|
|
16
|
+
Claude Code, GitHub Copilot, and Z.AI/goz.
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
quse
|
|
20
|
+
quse codex
|
|
21
|
+
quse copilot --json
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The CLI prints one line per provider by default. `--json` emits the same record
|
|
25
|
+
shape as JSON.
|
|
26
|
+
|
|
27
|
+
Supported providers:
|
|
28
|
+
|
|
29
|
+
- `codex`
|
|
30
|
+
- `claude`
|
|
31
|
+
- `copilot`
|
|
32
|
+
- `zai`
|
|
33
|
+
|
|
34
|
+
`gemini` is accepted and reports `unsupported` because it does not currently
|
|
35
|
+
expose a usage endpoint.
|
|
36
|
+
|
quse-0.0.1/README.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# quse
|
|
2
|
+
|
|
3
|
+
Quota and usage checks for coding-agent CLIs.
|
|
4
|
+
|
|
5
|
+
`quse` reports normalized usage for providers used by tools such as Codex,
|
|
6
|
+
Claude Code, GitHub Copilot, and Z.AI/goz.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
quse
|
|
10
|
+
quse codex
|
|
11
|
+
quse copilot --json
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The CLI prints one line per provider by default. `--json` emits the same record
|
|
15
|
+
shape as JSON.
|
|
16
|
+
|
|
17
|
+
Supported providers:
|
|
18
|
+
|
|
19
|
+
- `codex`
|
|
20
|
+
- `claude`
|
|
21
|
+
- `copilot`
|
|
22
|
+
- `zai`
|
|
23
|
+
|
|
24
|
+
`gemini` is accepted and reports `unsupported` because it does not currently
|
|
25
|
+
expose a usage endpoint.
|
|
26
|
+
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "quse"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
description = "Quota and usage checks for coding-agent CLIs."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
authors = [{name = "Alexey Grigorev"}]
|
|
12
|
+
license = {text = "MIT"}
|
|
13
|
+
dependencies = [
|
|
14
|
+
"click>=8.2.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[dependency-groups]
|
|
18
|
+
dev = [
|
|
19
|
+
"pytest>=8.4.0",
|
|
20
|
+
"hatch>=1.14.0",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.scripts]
|
|
24
|
+
quse = "quse.cli:main"
|
|
25
|
+
|
|
26
|
+
[tool.hatch.build.targets.wheel]
|
|
27
|
+
packages = ["quse"]
|
|
28
|
+
|
|
29
|
+
[tool.pytest.ini_options]
|
|
30
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Shared quota status models and normalization helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(slots=True)
|
|
10
|
+
class UsageWindow:
|
|
11
|
+
percent_remaining: float = 100.0
|
|
12
|
+
reset_at: str | None = None
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def used_percent(self) -> float:
|
|
16
|
+
return max(0.0, 100.0 - self.percent_remaining)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(slots=True)
|
|
20
|
+
class UsageStatus:
|
|
21
|
+
limit_reached: bool = False
|
|
22
|
+
short_term: UsageWindow = field(default_factory=UsageWindow)
|
|
23
|
+
long_term: UsageWindow = field(default_factory=UsageWindow)
|
|
24
|
+
checked_at: float = 0.0
|
|
25
|
+
error: str | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def preferred_reset_at(
|
|
29
|
+
status: UsageStatus,
|
|
30
|
+
*,
|
|
31
|
+
include_short_term_fallback: bool = False,
|
|
32
|
+
) -> str | None:
|
|
33
|
+
if status.long_term.reset_at:
|
|
34
|
+
return status.long_term.reset_at
|
|
35
|
+
if include_short_term_fallback:
|
|
36
|
+
return status.short_term.reset_at
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def usage_limit_block_reason(engine_name: str, status: UsageStatus) -> str | None:
|
|
41
|
+
if status.error:
|
|
42
|
+
return None
|
|
43
|
+
if engine_name == "codex":
|
|
44
|
+
from quse.codex_quota import codex_quota_block_reason
|
|
45
|
+
|
|
46
|
+
return codex_quota_block_reason()
|
|
47
|
+
if engine_name == "claude":
|
|
48
|
+
from quse.claude_quota import claude_quota_block_reason
|
|
49
|
+
|
|
50
|
+
return claude_quota_block_reason()
|
|
51
|
+
if engine_name == "copilot":
|
|
52
|
+
from quse.copilot_quota import copilot_quota_block_reason
|
|
53
|
+
|
|
54
|
+
return copilot_quota_block_reason()
|
|
55
|
+
if engine_name in {"goz", "opencode"}:
|
|
56
|
+
from quse.zai_quota import zai_quota_block_reason
|
|
57
|
+
|
|
58
|
+
return zai_quota_block_reason()
|
|
59
|
+
if not status.limit_reached:
|
|
60
|
+
return None
|
|
61
|
+
reset_at = preferred_reset_at(status, include_short_term_fallback=True)
|
|
62
|
+
reset_suffix = f", resets {reset_at}" if reset_at else ""
|
|
63
|
+
return f"{engine_name} usage limit reached{reset_suffix}"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def normalize_reset_at(value: object) -> str | None:
|
|
67
|
+
if value is None:
|
|
68
|
+
return None
|
|
69
|
+
if isinstance(value, datetime):
|
|
70
|
+
parsed = value
|
|
71
|
+
else:
|
|
72
|
+
normalized = str(value).strip()
|
|
73
|
+
if not normalized:
|
|
74
|
+
return None
|
|
75
|
+
try:
|
|
76
|
+
parsed = datetime.fromisoformat(normalized.replace("Z", "+00:00"))
|
|
77
|
+
except ValueError:
|
|
78
|
+
try:
|
|
79
|
+
parsed = datetime.strptime(normalized, "%Y-%m-%d")
|
|
80
|
+
except ValueError:
|
|
81
|
+
return None
|
|
82
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
83
|
+
if parsed.tzinfo is None:
|
|
84
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
85
|
+
return parsed.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Proactive Claude quota checking via OAuth usage endpoint."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.request
|
|
12
|
+
|
|
13
|
+
from quse._shared import UsageWindow, normalize_reset_at
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
_USAGE_URL = "https://api.anthropic.com/api/oauth/usage"
|
|
18
|
+
_CACHE_TTL_SECONDS = 60
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(slots=True)
|
|
22
|
+
class ClaudeQuotaWindow:
|
|
23
|
+
used_percent: float = 0.0
|
|
24
|
+
reset_at: str | None = None
|
|
25
|
+
|
|
26
|
+
def __post_init__(self) -> None:
|
|
27
|
+
self.used_percent = float(self.used_percent)
|
|
28
|
+
self.reset_at = normalize_reset_at(self.reset_at)
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def percent_remaining(self) -> float:
|
|
32
|
+
return max(0.0, 100.0 - self.used_percent)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(slots=True)
|
|
36
|
+
class ClaudeQuotaStatus:
|
|
37
|
+
five_hour: ClaudeQuotaWindow = field(default_factory=ClaudeQuotaWindow)
|
|
38
|
+
seven_day: ClaudeQuotaWindow = field(default_factory=ClaudeQuotaWindow)
|
|
39
|
+
limit_reached: bool = False
|
|
40
|
+
checked_at: float = 0.0
|
|
41
|
+
error: str | None = None
|
|
42
|
+
subscription: str | None = None
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def short_term(self) -> UsageWindow:
|
|
46
|
+
return UsageWindow(percent_remaining=self.five_hour.percent_remaining, reset_at=self.five_hour.reset_at)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def long_term(self) -> UsageWindow:
|
|
50
|
+
return UsageWindow(percent_remaining=self.seven_day.percent_remaining, reset_at=self.seven_day.reset_at)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _default_credentials_path() -> Path:
|
|
54
|
+
"""Resolve Claude credentials path, respecting config dir overrides."""
|
|
55
|
+
config_dir = os.environ.get("CLAUDE_CONFIG_DIR")
|
|
56
|
+
if config_dir:
|
|
57
|
+
return Path(config_dir) / ".credentials.json"
|
|
58
|
+
return Path.home() / ".claude" / ".credentials.json"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
_cached_status: ClaudeQuotaStatus | None = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _read_access_token(creds_path: Path | None = None) -> str | None:
|
|
65
|
+
path = creds_path or _default_credentials_path()
|
|
66
|
+
try:
|
|
67
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
68
|
+
oauth = data.get("claudeAiOauth", {})
|
|
69
|
+
token = oauth.get("accessToken")
|
|
70
|
+
if token:
|
|
71
|
+
return token
|
|
72
|
+
logger.warning("claude credentials missing claudeAiOauth.accessToken")
|
|
73
|
+
return None
|
|
74
|
+
except FileNotFoundError:
|
|
75
|
+
logger.warning("claude credentials not found at %s", path)
|
|
76
|
+
return None
|
|
77
|
+
except (json.JSONDecodeError, KeyError, TypeError) as exc:
|
|
78
|
+
logger.warning("claude credentials parse error: %s", exc)
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _parse_usage_response(data: dict) -> ClaudeQuotaStatus:
|
|
83
|
+
five_hour_data = data.get("five_hour")
|
|
84
|
+
if not isinstance(five_hour_data, dict):
|
|
85
|
+
five_hour_data = {}
|
|
86
|
+
seven_day_data = data.get("seven_day")
|
|
87
|
+
if not isinstance(seven_day_data, dict):
|
|
88
|
+
seven_day_data = {}
|
|
89
|
+
|
|
90
|
+
five_hour = ClaudeQuotaWindow(
|
|
91
|
+
used_percent=five_hour_data.get("utilization", 0),
|
|
92
|
+
reset_at=five_hour_data.get("resets_at"),
|
|
93
|
+
)
|
|
94
|
+
seven_day = ClaudeQuotaWindow(
|
|
95
|
+
used_percent=seven_day_data.get("utilization", 0),
|
|
96
|
+
reset_at=seven_day_data.get("resets_at"),
|
|
97
|
+
)
|
|
98
|
+
subscription = data.get("subscription")
|
|
99
|
+
|
|
100
|
+
return ClaudeQuotaStatus(
|
|
101
|
+
five_hour=five_hour,
|
|
102
|
+
seven_day=seven_day,
|
|
103
|
+
limit_reached=seven_day.percent_remaining <= 5.0,
|
|
104
|
+
checked_at=time.monotonic(),
|
|
105
|
+
subscription=subscription if isinstance(subscription, str) and subscription else None,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _fetch_usage(token: str, *, timeout: float = 10.0) -> ClaudeQuotaStatus:
|
|
110
|
+
req = urllib.request.Request(
|
|
111
|
+
_USAGE_URL,
|
|
112
|
+
headers={
|
|
113
|
+
"Authorization": f"Bearer {token}",
|
|
114
|
+
"anthropic-beta": "oauth-2025-04-20",
|
|
115
|
+
},
|
|
116
|
+
method="GET",
|
|
117
|
+
)
|
|
118
|
+
try:
|
|
119
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
120
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
121
|
+
return _parse_usage_response(data)
|
|
122
|
+
except (urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError, OSError) as exc:
|
|
123
|
+
logger.warning("claude quota check failed (fail-open): %s", exc)
|
|
124
|
+
return ClaudeQuotaStatus(checked_at=time.monotonic(), error=str(exc))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def check_claude_quota(
|
|
128
|
+
*,
|
|
129
|
+
creds_path: Path | None = None,
|
|
130
|
+
cache_ttl: float = _CACHE_TTL_SECONDS,
|
|
131
|
+
_fetch: object = None,
|
|
132
|
+
) -> ClaudeQuotaStatus:
|
|
133
|
+
"""Check Claude quota proactively. Returns cached result within TTL.
|
|
134
|
+
|
|
135
|
+
Fails open: if auth is missing or API call fails, returns a non-blocking status.
|
|
136
|
+
"""
|
|
137
|
+
global _cached_status
|
|
138
|
+
if _cached_status is not None and time.monotonic() - _cached_status.checked_at < cache_ttl:
|
|
139
|
+
return _cached_status
|
|
140
|
+
|
|
141
|
+
token = _read_access_token(creds_path)
|
|
142
|
+
if token is None:
|
|
143
|
+
return ClaudeQuotaStatus(checked_at=time.monotonic(), error="no-credentials")
|
|
144
|
+
|
|
145
|
+
fetcher = _fetch if callable(_fetch) else _fetch_usage
|
|
146
|
+
_cached_status = fetcher(token)
|
|
147
|
+
return _cached_status
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def claude_quota_block_reason(
|
|
151
|
+
*,
|
|
152
|
+
creds_path: Path | None = None,
|
|
153
|
+
cache_ttl: float = _CACHE_TTL_SECONDS,
|
|
154
|
+
_fetch: object = None,
|
|
155
|
+
) -> str | None:
|
|
156
|
+
"""Return a blocking reason string if Claude quota is reached, or None."""
|
|
157
|
+
status = check_claude_quota(creds_path=creds_path, cache_ttl=cache_ttl, _fetch=_fetch)
|
|
158
|
+
if status.error:
|
|
159
|
+
return None # fail-open
|
|
160
|
+
if status.limit_reached:
|
|
161
|
+
return (
|
|
162
|
+
f"claude usage limit reached "
|
|
163
|
+
f"(long-term window at {status.long_term.used_percent:.0f}%, resets {status.long_term.reset_at})"
|
|
164
|
+
)
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def reset_cache() -> None:
|
|
169
|
+
global _cached_status
|
|
170
|
+
_cached_status = None
|
quse-0.0.1/quse/cli.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Command-line interface for quse."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from quse.usage import UnknownProviderError, collect_usage, format_usage_line
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@click.command(context_settings={"help_option_names": ["-h", "--help"]})
|
|
13
|
+
@click.argument("provider", required=False)
|
|
14
|
+
@click.option("--json", "json_output", is_flag=True, help="Emit machine-readable JSON output.")
|
|
15
|
+
def usage_command(
|
|
16
|
+
provider: str | None = None,
|
|
17
|
+
json_output: bool = False,
|
|
18
|
+
) -> int:
|
|
19
|
+
try:
|
|
20
|
+
records = collect_usage(provider)
|
|
21
|
+
except UnknownProviderError as exc:
|
|
22
|
+
raise click.ClickException(str(exc)) from exc
|
|
23
|
+
|
|
24
|
+
if json_output:
|
|
25
|
+
if len(records) == 1:
|
|
26
|
+
click.echo(json.dumps(records[0], sort_keys=True))
|
|
27
|
+
else:
|
|
28
|
+
for record in records:
|
|
29
|
+
click.echo(json.dumps(record, sort_keys=True))
|
|
30
|
+
return 0
|
|
31
|
+
|
|
32
|
+
for record in records:
|
|
33
|
+
click.echo(format_usage_line(record))
|
|
34
|
+
return 0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
app = usage_command
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def main(argv: list[str] | None = None) -> int:
|
|
41
|
+
try:
|
|
42
|
+
result = app.main(args=argv, prog_name="quse", standalone_mode=False)
|
|
43
|
+
except click.exceptions.Exit as exc:
|
|
44
|
+
return exc.exit_code
|
|
45
|
+
except click.ClickException as exc:
|
|
46
|
+
exc.show()
|
|
47
|
+
return exc.exit_code
|
|
48
|
+
return 0 if result is None else int(result)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
if __name__ == "__main__":
|
|
52
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Proactive codex quota checking via chatgpt.com API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.request
|
|
13
|
+
|
|
14
|
+
from quse._shared import UsageWindow, normalize_reset_at
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage"
|
|
19
|
+
_AUTH_PATH = Path.home() / ".codex" / "auth.json"
|
|
20
|
+
_CACHE_TTL_SECONDS = 60
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(slots=True)
|
|
24
|
+
class CodexQuotaWindow:
|
|
25
|
+
used_percent: float = 0.0
|
|
26
|
+
reset_at: str | None = None
|
|
27
|
+
|
|
28
|
+
def __post_init__(self) -> None:
|
|
29
|
+
self.used_percent = float(self.used_percent)
|
|
30
|
+
self.reset_at = normalize_reset_at(self.reset_at)
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def percent_remaining(self) -> float:
|
|
34
|
+
return max(0.0, 100.0 - self.used_percent)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(slots=True)
|
|
38
|
+
class CodexQuotaStatus:
|
|
39
|
+
primary_window: CodexQuotaWindow = field(default_factory=CodexQuotaWindow)
|
|
40
|
+
secondary_window: CodexQuotaWindow = field(default_factory=CodexQuotaWindow)
|
|
41
|
+
limit_reached: bool = False
|
|
42
|
+
checked_at: float = 0.0
|
|
43
|
+
error: str | None = None
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def short_term(self) -> UsageWindow:
|
|
47
|
+
return UsageWindow(percent_remaining=100.0)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def long_term(self) -> UsageWindow:
|
|
51
|
+
return UsageWindow(
|
|
52
|
+
percent_remaining=self.secondary_window.percent_remaining,
|
|
53
|
+
reset_at=self.secondary_window.reset_at,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def earliest_reset_at(self) -> str | None:
|
|
58
|
+
reset_candidates = [value for value in (self.primary_window.reset_at, self.secondary_window.reset_at) if value]
|
|
59
|
+
return min(reset_candidates) if reset_candidates else None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
_cached_status: CodexQuotaStatus | None = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _read_bearer_token(auth_path: Path | None = None) -> str | None:
|
|
66
|
+
path = auth_path or _AUTH_PATH
|
|
67
|
+
try:
|
|
68
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
69
|
+
token = data.get("tokens", {}).get("access_token")
|
|
70
|
+
if token:
|
|
71
|
+
return token
|
|
72
|
+
logger.warning("codex auth.json missing tokens.access_token")
|
|
73
|
+
return None
|
|
74
|
+
except FileNotFoundError:
|
|
75
|
+
logger.warning("codex auth.json not found at %s", path)
|
|
76
|
+
return None
|
|
77
|
+
except (json.JSONDecodeError, KeyError, TypeError) as exc:
|
|
78
|
+
logger.warning("codex auth.json parse error: %s", exc)
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _parse_quota_response(data: dict) -> CodexQuotaStatus:
|
|
83
|
+
rate_limit = data.get("rate_limit")
|
|
84
|
+
if not isinstance(rate_limit, dict):
|
|
85
|
+
rate_limit = {}
|
|
86
|
+
primary_data = rate_limit.get("primary_window")
|
|
87
|
+
if not isinstance(primary_data, dict):
|
|
88
|
+
primary_data = {}
|
|
89
|
+
secondary_data = rate_limit.get("secondary_window")
|
|
90
|
+
if not isinstance(secondary_data, dict):
|
|
91
|
+
secondary_data = {}
|
|
92
|
+
|
|
93
|
+
primary_window = CodexQuotaWindow(
|
|
94
|
+
used_percent=primary_data.get("used_percent", 0),
|
|
95
|
+
reset_at=primary_data.get("reset_at"),
|
|
96
|
+
)
|
|
97
|
+
secondary_window = CodexQuotaWindow(
|
|
98
|
+
used_percent=secondary_data.get("used_percent", 0),
|
|
99
|
+
reset_at=secondary_data.get("reset_at"),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
return CodexQuotaStatus(
|
|
103
|
+
primary_window=primary_window,
|
|
104
|
+
secondary_window=secondary_window,
|
|
105
|
+
limit_reached=bool(rate_limit.get("limit_reached", False)) or secondary_window.used_percent >= 80.0,
|
|
106
|
+
checked_at=time.monotonic(),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _fetch_quota(token: str, *, timeout: float = 10.0) -> CodexQuotaStatus:
|
|
111
|
+
req = urllib.request.Request(
|
|
112
|
+
_USAGE_URL,
|
|
113
|
+
headers={
|
|
114
|
+
"Authorization": f"Bearer {token}",
|
|
115
|
+
"Accept": "application/json",
|
|
116
|
+
},
|
|
117
|
+
method="GET",
|
|
118
|
+
)
|
|
119
|
+
try:
|
|
120
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
121
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
122
|
+
return _parse_quota_response(data)
|
|
123
|
+
except (urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError, OSError) as exc:
|
|
124
|
+
logger.warning("codex quota check failed (fail-open): %s", exc)
|
|
125
|
+
return CodexQuotaStatus(checked_at=time.monotonic(), error=str(exc))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def check_codex_quota(
|
|
129
|
+
*,
|
|
130
|
+
auth_path: Path | None = None,
|
|
131
|
+
cache_ttl: float = _CACHE_TTL_SECONDS,
|
|
132
|
+
_fetch: object = None,
|
|
133
|
+
) -> CodexQuotaStatus:
|
|
134
|
+
"""Check codex quota proactively. Returns cached result within TTL.
|
|
135
|
+
|
|
136
|
+
Fails open: if auth is missing or API call fails, returns a non-blocking status.
|
|
137
|
+
"""
|
|
138
|
+
global _cached_status
|
|
139
|
+
now = time.monotonic()
|
|
140
|
+
|
|
141
|
+
if _cached_status is not None and (now - _cached_status.checked_at) < cache_ttl:
|
|
142
|
+
return _cached_status
|
|
143
|
+
|
|
144
|
+
token = _read_bearer_token(auth_path)
|
|
145
|
+
if token is None:
|
|
146
|
+
status = CodexQuotaStatus(checked_at=now, error="no auth token")
|
|
147
|
+
_cached_status = status
|
|
148
|
+
return status
|
|
149
|
+
|
|
150
|
+
fetcher = _fetch if callable(_fetch) else _fetch_quota
|
|
151
|
+
status = fetcher(token)
|
|
152
|
+
_cached_status = status
|
|
153
|
+
return status
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def codex_quota_block_reason(
|
|
157
|
+
*,
|
|
158
|
+
auth_path: Path | None = None,
|
|
159
|
+
cache_ttl: float = _CACHE_TTL_SECONDS,
|
|
160
|
+
_fetch: object = None,
|
|
161
|
+
) -> str | None:
|
|
162
|
+
"""Return a blocking reason string if codex quota is exhausted, or None if OK."""
|
|
163
|
+
status = check_codex_quota(auth_path=auth_path, cache_ttl=cache_ttl, _fetch=_fetch)
|
|
164
|
+
if status.error is not None:
|
|
165
|
+
return None # fail-open
|
|
166
|
+
if status.limit_reached:
|
|
167
|
+
reset_info = f", resets {status.long_term.reset_at}" if status.long_term.reset_at else ""
|
|
168
|
+
return f"codex quota exhausted (weekly window at {status.long_term.used_percent:.0f}%{reset_info})"
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def reset_cache() -> None:
|
|
173
|
+
"""Clear the cached quota status (useful for testing)."""
|
|
174
|
+
global _cached_status
|
|
175
|
+
_cached_status = None
|