quse 0.0.1__py3-none-any.whl
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/__init__.py +8 -0
- quse/_shared.py +85 -0
- quse/claude_quota.py +170 -0
- quse/cli.py +52 -0
- quse/codex_quota.py +175 -0
- quse/copilot_quota.py +119 -0
- quse/usage.py +223 -0
- quse/zai_quota.py +124 -0
- quse-0.0.1.dist-info/METADATA +36 -0
- quse-0.0.1.dist-info/RECORD +12 -0
- quse-0.0.1.dist-info/WHEEL +4 -0
- quse-0.0.1.dist-info/entry_points.txt +2 -0
quse/__init__.py
ADDED
quse/_shared.py
ADDED
|
@@ -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")
|
quse/claude_quota.py
ADDED
|
@@ -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/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())
|
quse/codex_quota.py
ADDED
|
@@ -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
|
quse/copilot_quota.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Proactive Copilot quota checking via GitHub API."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import subprocess
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
from quse._shared import UsageWindow, normalize_reset_at
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
_CACHE_TTL_SECONDS = 60
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(slots=True)
|
|
17
|
+
class CopilotQuotaStatus:
|
|
18
|
+
premium_remaining: int | None = None
|
|
19
|
+
premium_entitlement: int | None = None
|
|
20
|
+
premium_percent_remaining: float = 100.0
|
|
21
|
+
quota_reset_date: str | None = None
|
|
22
|
+
checked_at: float = 0.0
|
|
23
|
+
error: str | None = None
|
|
24
|
+
|
|
25
|
+
def __post_init__(self) -> None:
|
|
26
|
+
self.premium_percent_remaining = float(self.premium_percent_remaining)
|
|
27
|
+
self.quota_reset_date = normalize_reset_at(self.quota_reset_date)
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def used_percent(self) -> float:
|
|
31
|
+
return max(0.0, 100.0 - self.premium_percent_remaining)
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def limit_reached(self) -> bool:
|
|
35
|
+
return self.error is None and self.premium_percent_remaining <= 20.0
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def short_term(self) -> UsageWindow:
|
|
39
|
+
return UsageWindow(percent_remaining=100.0)
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def long_term(self) -> UsageWindow:
|
|
43
|
+
return UsageWindow(percent_remaining=self.premium_percent_remaining, reset_at=self.quota_reset_date)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
_cached_status: CopilotQuotaStatus | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _fetch_quota(*, timeout: float = 10.0) -> CopilotQuotaStatus:
|
|
50
|
+
try:
|
|
51
|
+
result = subprocess.run(
|
|
52
|
+
["gh", "api", "/copilot_internal/user"],
|
|
53
|
+
capture_output=True,
|
|
54
|
+
text=True,
|
|
55
|
+
timeout=timeout,
|
|
56
|
+
)
|
|
57
|
+
if result.returncode != 0:
|
|
58
|
+
return CopilotQuotaStatus(checked_at=time.monotonic(), error=f"gh exit {result.returncode}")
|
|
59
|
+
data = json.loads(result.stdout)
|
|
60
|
+
except FileNotFoundError:
|
|
61
|
+
return CopilotQuotaStatus(checked_at=time.monotonic(), error="gh not on PATH")
|
|
62
|
+
except (subprocess.TimeoutExpired, json.JSONDecodeError, OSError) as exc:
|
|
63
|
+
logger.warning("copilot quota check failed (fail-open): %s", exc)
|
|
64
|
+
return CopilotQuotaStatus(checked_at=time.monotonic(), error=str(exc))
|
|
65
|
+
|
|
66
|
+
snapshots = data.get("quota_snapshots")
|
|
67
|
+
if not isinstance(snapshots, dict):
|
|
68
|
+
snapshots = {}
|
|
69
|
+
premium = snapshots.get("premium_interactions")
|
|
70
|
+
if not isinstance(premium, dict):
|
|
71
|
+
premium = {}
|
|
72
|
+
|
|
73
|
+
if premium.get("unlimited", False):
|
|
74
|
+
return CopilotQuotaStatus(checked_at=time.monotonic())
|
|
75
|
+
|
|
76
|
+
return CopilotQuotaStatus(
|
|
77
|
+
premium_remaining=premium.get("remaining") if isinstance(premium.get("remaining"), int) else None,
|
|
78
|
+
premium_entitlement=premium.get("entitlement") if isinstance(premium.get("entitlement"), int) else None,
|
|
79
|
+
premium_percent_remaining=max(0.0, float(premium.get("percent_remaining", 100.0))),
|
|
80
|
+
quota_reset_date=data.get("quota_reset_date"),
|
|
81
|
+
checked_at=time.monotonic(),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def check_copilot_quota(
|
|
86
|
+
*,
|
|
87
|
+
cache_ttl: float = _CACHE_TTL_SECONDS,
|
|
88
|
+
_fetch: object = None,
|
|
89
|
+
) -> CopilotQuotaStatus:
|
|
90
|
+
"""Check Copilot quota via gh CLI. Returns cached result within TTL. Fails open."""
|
|
91
|
+
global _cached_status
|
|
92
|
+
if _cached_status is not None and time.monotonic() - _cached_status.checked_at < cache_ttl:
|
|
93
|
+
return _cached_status
|
|
94
|
+
|
|
95
|
+
fetcher = _fetch if callable(_fetch) else _fetch_quota
|
|
96
|
+
_cached_status = fetcher()
|
|
97
|
+
return _cached_status
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def copilot_quota_block_reason(
|
|
101
|
+
*,
|
|
102
|
+
cache_ttl: float = _CACHE_TTL_SECONDS,
|
|
103
|
+
_fetch: object = None,
|
|
104
|
+
) -> str | None:
|
|
105
|
+
"""Return a blocking reason if Copilot quota is reached, or None."""
|
|
106
|
+
status = check_copilot_quota(cache_ttl=cache_ttl, _fetch=_fetch)
|
|
107
|
+
if status.error:
|
|
108
|
+
return None # fail-open
|
|
109
|
+
if status.limit_reached:
|
|
110
|
+
return (
|
|
111
|
+
f"copilot premium requests low "
|
|
112
|
+
f"({status.long_term.percent_remaining:.0f}% remaining, resets {status.long_term.reset_at})"
|
|
113
|
+
)
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def reset_cache() -> None:
|
|
118
|
+
global _cached_status
|
|
119
|
+
_cached_status = None
|
quse/usage.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Normalized usage records for supported coding-agent providers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from quse import claude_quota, codex_quota, copilot_quota, zai_quota
|
|
9
|
+
|
|
10
|
+
USAGE_PROVIDER_CHOICES = ("codex", "claude", "copilot", "zai", "gemini")
|
|
11
|
+
SUPPORTED_USAGE_PROVIDERS = ("codex", "claude", "copilot", "zai")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class UnknownProviderError(ValueError):
|
|
15
|
+
"""Raised when a provider name is not supported."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def usage_provider_error_message(name: str) -> str:
|
|
19
|
+
valid_names = ", ".join(USAGE_PROVIDER_CHOICES)
|
|
20
|
+
return f"Unknown provider '{name}'. Valid provider names: {valid_names}."
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def usage_window_record(
|
|
24
|
+
*,
|
|
25
|
+
provider: str,
|
|
26
|
+
status: str,
|
|
27
|
+
used: float | int | None,
|
|
28
|
+
limit: float | int | None,
|
|
29
|
+
remaining: float | int | None,
|
|
30
|
+
unit: str | None,
|
|
31
|
+
reset_window: str | None,
|
|
32
|
+
reset_at: str | None,
|
|
33
|
+
block_reason: str | None,
|
|
34
|
+
error: str | None = None,
|
|
35
|
+
details: dict[str, Any] | None = None,
|
|
36
|
+
) -> dict[str, Any]:
|
|
37
|
+
return {
|
|
38
|
+
"provider": provider,
|
|
39
|
+
"status": status,
|
|
40
|
+
"used": used,
|
|
41
|
+
"limit": limit,
|
|
42
|
+
"remaining": remaining,
|
|
43
|
+
"unit": unit,
|
|
44
|
+
"reset_window": reset_window,
|
|
45
|
+
"reset_at": reset_at,
|
|
46
|
+
"block_reason": block_reason,
|
|
47
|
+
"error": error,
|
|
48
|
+
"details": details or {},
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _pick_named_window(windows: dict[str, Any]) -> tuple[str, Any]:
|
|
53
|
+
return max(windows.items(), key=lambda item: item[1].used_percent)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def normalize_usage_provider(provider: str) -> dict[str, Any]:
|
|
57
|
+
if provider == "gemini":
|
|
58
|
+
return usage_window_record(
|
|
59
|
+
provider="gemini",
|
|
60
|
+
status="unsupported",
|
|
61
|
+
used=None,
|
|
62
|
+
limit=None,
|
|
63
|
+
remaining=None,
|
|
64
|
+
unit=None,
|
|
65
|
+
reset_window=None,
|
|
66
|
+
reset_at=None,
|
|
67
|
+
block_reason=None,
|
|
68
|
+
error="unsupported",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
if provider == "codex":
|
|
72
|
+
status_obj = codex_quota.check_codex_quota()
|
|
73
|
+
block_reason = codex_quota.codex_quota_block_reason()
|
|
74
|
+
windows = {
|
|
75
|
+
"primary_window": status_obj.primary_window,
|
|
76
|
+
"secondary_window": status_obj.secondary_window,
|
|
77
|
+
}
|
|
78
|
+
selected_name, selected_window = _pick_named_window(windows)
|
|
79
|
+
status = "error" if status_obj.error else "blocked" if block_reason else "ok"
|
|
80
|
+
used = round(selected_window.used_percent, 2)
|
|
81
|
+
limit = 100.0
|
|
82
|
+
remaining = round(max(0.0, limit - used), 2)
|
|
83
|
+
return usage_window_record(
|
|
84
|
+
provider=provider,
|
|
85
|
+
status=status,
|
|
86
|
+
used=None if status_obj.error else used,
|
|
87
|
+
limit=None if status_obj.error else limit,
|
|
88
|
+
remaining=None if status_obj.error else remaining,
|
|
89
|
+
unit="percent",
|
|
90
|
+
reset_window=selected_name,
|
|
91
|
+
reset_at=selected_window.reset_at or status_obj.earliest_reset_at,
|
|
92
|
+
block_reason=block_reason,
|
|
93
|
+
error=status_obj.error,
|
|
94
|
+
details={
|
|
95
|
+
"limit_reached": status_obj.limit_reached,
|
|
96
|
+
"windows": {
|
|
97
|
+
"primary_window": asdict(status_obj.primary_window),
|
|
98
|
+
"secondary_window": asdict(status_obj.secondary_window),
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if provider == "claude":
|
|
104
|
+
status_obj = claude_quota.check_claude_quota()
|
|
105
|
+
block_reason = claude_quota.claude_quota_block_reason()
|
|
106
|
+
windows = {
|
|
107
|
+
"five_hour": status_obj.five_hour,
|
|
108
|
+
"seven_day": status_obj.seven_day,
|
|
109
|
+
}
|
|
110
|
+
selected_name, selected_window = _pick_named_window(windows)
|
|
111
|
+
status = "error" if status_obj.error else "blocked" if block_reason else "ok"
|
|
112
|
+
used = round(selected_window.used_percent, 2)
|
|
113
|
+
limit = 100.0
|
|
114
|
+
remaining = round(max(0.0, limit - used), 2)
|
|
115
|
+
return usage_window_record(
|
|
116
|
+
provider=provider,
|
|
117
|
+
status=status,
|
|
118
|
+
used=None if status_obj.error else used,
|
|
119
|
+
limit=None if status_obj.error else limit,
|
|
120
|
+
remaining=None if status_obj.error else remaining,
|
|
121
|
+
unit="percent",
|
|
122
|
+
reset_window=selected_name,
|
|
123
|
+
reset_at=selected_window.reset_at,
|
|
124
|
+
block_reason=block_reason,
|
|
125
|
+
error=status_obj.error,
|
|
126
|
+
details={
|
|
127
|
+
"limit_reached": status_obj.limit_reached,
|
|
128
|
+
"subscription": status_obj.subscription,
|
|
129
|
+
"windows": {
|
|
130
|
+
"five_hour": asdict(status_obj.five_hour),
|
|
131
|
+
"seven_day": asdict(status_obj.seven_day),
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
if provider == "copilot":
|
|
137
|
+
status_obj = copilot_quota.check_copilot_quota()
|
|
138
|
+
block_reason = copilot_quota.copilot_quota_block_reason()
|
|
139
|
+
status = "error" if status_obj.error else "blocked" if block_reason else "ok"
|
|
140
|
+
used = round(status_obj.used_percent, 2)
|
|
141
|
+
limit = status_obj.premium_entitlement
|
|
142
|
+
remaining = status_obj.premium_remaining
|
|
143
|
+
return usage_window_record(
|
|
144
|
+
provider=provider,
|
|
145
|
+
status=status,
|
|
146
|
+
used=None if status_obj.error else used,
|
|
147
|
+
limit=None if status_obj.error else limit,
|
|
148
|
+
remaining=None if status_obj.error else remaining,
|
|
149
|
+
unit="premium_interactions",
|
|
150
|
+
reset_window="monthly",
|
|
151
|
+
reset_at=status_obj.quota_reset_date,
|
|
152
|
+
block_reason=block_reason,
|
|
153
|
+
error=status_obj.error,
|
|
154
|
+
details={
|
|
155
|
+
"premium_percent_remaining": status_obj.premium_percent_remaining,
|
|
156
|
+
"limit_reached": status_obj.limit_reached,
|
|
157
|
+
},
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
if provider == "zai":
|
|
161
|
+
status_obj = zai_quota.check_zai_quota()
|
|
162
|
+
block_reason = zai_quota.zai_quota_block_reason()
|
|
163
|
+
windows = {
|
|
164
|
+
"api_calls": status_obj.api_calls,
|
|
165
|
+
"tokens": status_obj.tokens,
|
|
166
|
+
}
|
|
167
|
+
selected_name, selected_window = _pick_named_window(windows)
|
|
168
|
+
status = "error" if status_obj.error else "blocked" if block_reason else "ok"
|
|
169
|
+
used = None
|
|
170
|
+
if selected_window.limit is not None and selected_window.remaining is not None:
|
|
171
|
+
used = selected_window.limit - selected_window.remaining
|
|
172
|
+
return usage_window_record(
|
|
173
|
+
provider=provider,
|
|
174
|
+
status=status,
|
|
175
|
+
used=None if status_obj.error else used,
|
|
176
|
+
limit=None if status_obj.error else selected_window.limit,
|
|
177
|
+
remaining=None if status_obj.error else selected_window.remaining,
|
|
178
|
+
unit=selected_name,
|
|
179
|
+
reset_window=f"{selected_window.window_hours}h" if selected_window.window_hours else selected_name,
|
|
180
|
+
reset_at=None,
|
|
181
|
+
block_reason=block_reason,
|
|
182
|
+
error=status_obj.error,
|
|
183
|
+
details={
|
|
184
|
+
"limit_reached": status_obj.limit_reached,
|
|
185
|
+
"max_used_percent": status_obj.max_used_percent,
|
|
186
|
+
"windows": {
|
|
187
|
+
"api_calls": asdict(status_obj.api_calls),
|
|
188
|
+
"tokens": asdict(status_obj.tokens),
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
raise UnknownProviderError(usage_provider_error_message(provider))
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def format_usage_line(record: dict[str, Any]) -> str:
|
|
197
|
+
fields = [
|
|
198
|
+
record["provider"] + ":",
|
|
199
|
+
f"status={record['status']}",
|
|
200
|
+
f"used={record['used'] if record['used'] is not None else 'unknown'}",
|
|
201
|
+
f"limit={record['limit'] if record['limit'] is not None else 'unknown'}",
|
|
202
|
+
f"remaining={record['remaining'] if record['remaining'] is not None else 'unknown'}",
|
|
203
|
+
f"unit={record['unit'] or 'unknown'}",
|
|
204
|
+
f"reset_window={record['reset_window'] or 'unknown'}",
|
|
205
|
+
f"reset_at={record['reset_at'] or 'unknown'}",
|
|
206
|
+
]
|
|
207
|
+
if record["block_reason"]:
|
|
208
|
+
fields.append(f"block_reason={record['block_reason']}")
|
|
209
|
+
if record["error"]:
|
|
210
|
+
fields.append(f"error={record['error']}")
|
|
211
|
+
return " ".join(fields)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def selected_providers(provider: str | None) -> list[str]:
|
|
215
|
+
providers = [provider] if provider is not None else list(SUPPORTED_USAGE_PROVIDERS)
|
|
216
|
+
for name in providers:
|
|
217
|
+
if name not in USAGE_PROVIDER_CHOICES:
|
|
218
|
+
raise UnknownProviderError(usage_provider_error_message(name))
|
|
219
|
+
return providers
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def collect_usage(provider: str | None = None) -> list[dict[str, Any]]:
|
|
223
|
+
return [normalize_usage_provider(name) for name in selected_providers(provider)]
|
quse/zai_quota.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Proactive Z.AI quota checking via goz CLI."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import subprocess
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
from quse._shared import UsageWindow
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
_CACHE_TTL_SECONDS = 60
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(slots=True)
|
|
17
|
+
class ZaiQuotaWindow:
|
|
18
|
+
used_percent: float = 0.0
|
|
19
|
+
window_hours: int | None = None
|
|
20
|
+
remaining: int | None = None
|
|
21
|
+
limit: int | None = None
|
|
22
|
+
|
|
23
|
+
def __post_init__(self) -> None:
|
|
24
|
+
self.used_percent = float(self.used_percent)
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def percent_remaining(self) -> float:
|
|
28
|
+
return max(0.0, 100.0 - self.used_percent)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(slots=True)
|
|
32
|
+
class ZaiQuotaStatus:
|
|
33
|
+
api_calls: ZaiQuotaWindow = field(default_factory=ZaiQuotaWindow)
|
|
34
|
+
tokens: ZaiQuotaWindow = field(default_factory=ZaiQuotaWindow)
|
|
35
|
+
limit_reached: bool = False
|
|
36
|
+
checked_at: float = 0.0
|
|
37
|
+
error: str | None = None
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def max_used_percent(self) -> float:
|
|
41
|
+
return max(self.api_calls.used_percent, self.tokens.used_percent)
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def short_term(self) -> UsageWindow:
|
|
45
|
+
return UsageWindow(percent_remaining=self.tokens.percent_remaining)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def long_term(self) -> UsageWindow:
|
|
49
|
+
return UsageWindow(percent_remaining=100.0)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_cached_status: ZaiQuotaStatus | None = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _fetch_usage(*, timeout: float = 10.0) -> ZaiQuotaStatus:
|
|
56
|
+
try:
|
|
57
|
+
result = subprocess.run(
|
|
58
|
+
["goz", "usage", "--json"],
|
|
59
|
+
capture_output=True,
|
|
60
|
+
text=True,
|
|
61
|
+
timeout=timeout,
|
|
62
|
+
)
|
|
63
|
+
if result.returncode != 0:
|
|
64
|
+
return ZaiQuotaStatus(checked_at=time.monotonic(), error=f"goz exit {result.returncode}")
|
|
65
|
+
data = json.loads(result.stdout)
|
|
66
|
+
except FileNotFoundError:
|
|
67
|
+
return ZaiQuotaStatus(checked_at=time.monotonic(), error="goz not on PATH")
|
|
68
|
+
except (subprocess.TimeoutExpired, json.JSONDecodeError, OSError) as exc:
|
|
69
|
+
logger.warning("zai quota check failed (fail-open): %s", exc)
|
|
70
|
+
return ZaiQuotaStatus(checked_at=time.monotonic(), error=str(exc))
|
|
71
|
+
|
|
72
|
+
api_calls = ZaiQuotaWindow()
|
|
73
|
+
tokens = ZaiQuotaWindow()
|
|
74
|
+
|
|
75
|
+
limits = data.get("limits")
|
|
76
|
+
if not isinstance(limits, list):
|
|
77
|
+
limits = []
|
|
78
|
+
for limit in limits:
|
|
79
|
+
if not isinstance(limit, dict):
|
|
80
|
+
continue
|
|
81
|
+
window = ZaiQuotaWindow(
|
|
82
|
+
used_percent=limit.get("percentage", 0),
|
|
83
|
+
window_hours=limit.get("window_hours") if isinstance(limit.get("window_hours"), int) else None,
|
|
84
|
+
remaining=limit.get("remaining") if isinstance(limit.get("remaining"), int) else None,
|
|
85
|
+
limit=limit.get("limit") if isinstance(limit.get("limit"), int) else None,
|
|
86
|
+
)
|
|
87
|
+
if limit.get("type") == "TIME_LIMIT":
|
|
88
|
+
api_calls = window
|
|
89
|
+
if limit.get("type") == "TOKENS_LIMIT":
|
|
90
|
+
tokens = window
|
|
91
|
+
|
|
92
|
+
return ZaiQuotaStatus(api_calls=api_calls, tokens=tokens, checked_at=time.monotonic())
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def check_zai_quota(
|
|
96
|
+
*,
|
|
97
|
+
cache_ttl: float = _CACHE_TTL_SECONDS,
|
|
98
|
+
_fetch: object = None,
|
|
99
|
+
) -> ZaiQuotaStatus:
|
|
100
|
+
"""Check Z.AI quota via goz CLI. Returns cached result within TTL. Fails open."""
|
|
101
|
+
global _cached_status
|
|
102
|
+
if _cached_status is not None and time.monotonic() - _cached_status.checked_at < cache_ttl:
|
|
103
|
+
return _cached_status
|
|
104
|
+
|
|
105
|
+
fetcher = _fetch if callable(_fetch) else _fetch_usage
|
|
106
|
+
_cached_status = fetcher()
|
|
107
|
+
return _cached_status
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def zai_quota_block_reason(
|
|
111
|
+
*,
|
|
112
|
+
cache_ttl: float = _CACHE_TTL_SECONDS,
|
|
113
|
+
_fetch: object = None,
|
|
114
|
+
) -> str | None:
|
|
115
|
+
"""Return a blocking reason if Z.AI quota is reached, or None."""
|
|
116
|
+
status = check_zai_quota(cache_ttl=cache_ttl, _fetch=_fetch)
|
|
117
|
+
if status.error:
|
|
118
|
+
return None # fail-open
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def reset_cache() -> None:
|
|
123
|
+
global _cached_status
|
|
124
|
+
_cached_status = None
|
|
@@ -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
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
quse/__init__.py,sha256=De4Gv1VWfhpJ2DHdFCadlYDlDmPGc2DMKx7qL--f-1s,219
|
|
2
|
+
quse/_shared.py,sha256=-H4x7niCx0VoP-aCFDgIlNREgjBHEZx5RJryZ90GpQU,2626
|
|
3
|
+
quse/claude_quota.py,sha256=MP4pqlQBgeyt4CGKlyV2xRU849nUdsixjlCT13-Ls5g,5563
|
|
4
|
+
quse/cli.py,sha256=F7zxcQf7MkhI_JLXay-lMKJ1VKt8anBthDVb39aGz28,1387
|
|
5
|
+
quse/codex_quota.py,sha256=VnRQwVMREsYJwJs5p-STRDrstsA7VxcyNRFWNyorx1g,5695
|
|
6
|
+
quse/copilot_quota.py,sha256=D4V78_fyPT8ufROk5i-2TPkzSepaJqaWyRLP-1psh7U,3969
|
|
7
|
+
quse/usage.py,sha256=2ZRh2TmGbpd9UNQE95kywEVyP9CjakSGEjucYCpC5RQ,8464
|
|
8
|
+
quse/zai_quota.py,sha256=gpOXcbvTtJmYOpY4z6vhKZC4wJFW8lnCrcGb1sXv0MA,3829
|
|
9
|
+
quse-0.0.1.dist-info/METADATA,sha256=pB-aBne2aBT8oFFdpFmV_GtkYv1-yrqGMdIweKz1stM,719
|
|
10
|
+
quse-0.0.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
11
|
+
quse-0.0.1.dist-info/entry_points.txt,sha256=C5lr1bV7dSOyw7Mx09Bs3TiT8hWjoJ4zy0RENoQoX9g,39
|
|
12
|
+
quse-0.0.1.dist-info/RECORD,,
|