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 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,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 *
@@ -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