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 ADDED
@@ -0,0 +1,8 @@
1
+ """Quota helpers for engine adapters."""
2
+
3
+ from quse._shared import *
4
+ from quse.claude_quota import *
5
+ from quse.codex_quota import *
6
+ from quse.copilot_quota import *
7
+ from quse.usage import *
8
+ from quse.zai_quota import *
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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ quse = quse.cli:main