ccx-native 0.0.0-stage → 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +241 -2
- package/bin/ccx +56 -0
- package/ccx/__init__.py +163 -0
- package/ccx/__main__.py +28 -0
- package/ccx/claude/__init__.py +0 -0
- package/ccx/claude/analytics.py +466 -0
- package/ccx/claude/attach.py +762 -0
- package/ccx/claude/context.py +465 -0
- package/ccx/claude/delivery.py +806 -0
- package/ccx/claude/external.py +253 -0
- package/ccx/claude/host/__init__.py +0 -0
- package/ccx/claude/host/events.py +297 -0
- package/ccx/claude/host/hooks.py +438 -0
- package/ccx/claude/host/pty.py +250 -0
- package/ccx/claude/host/session.py +5184 -0
- package/ccx/claude/host/subagents.py +169 -0
- package/ccx/claude/host/successor.py +267 -0
- package/ccx/claude/managed_turns.py +240 -0
- package/ccx/claude/oauth.py +176 -0
- package/ccx/claude/settings.py +339 -0
- package/ccx/claude/spawn.py +557 -0
- package/ccx/claude/tasks.py +72 -0
- package/ccx/claude/telemetry.py +282 -0
- package/ccx/claude/transcript.py +2281 -0
- package/ccx/claude/usage.py +837 -0
- package/ccx/claude/usage_control.py +152 -0
- package/ccx/cli/__init__.py +0 -0
- package/ccx/cli/codex.py +1009 -0
- package/ccx/cli/commands/__init__.py +0 -0
- package/ccx/cli/commands/_common.py +407 -0
- package/ccx/cli/commands/control.py +161 -0
- package/ccx/cli/commands/create.py +669 -0
- package/ccx/cli/commands/read.py +1020 -0
- package/ccx/cli/commands/turns.py +1081 -0
- package/ccx/cli/controller.py +757 -0
- package/ccx/cli/main.py +301 -0
- package/ccx/cli/parser.py +1324 -0
- package/ccx/cli/skills.py +215 -0
- package/ccx/cli/statusline.py +160 -0
- package/ccx/client.py +1900 -0
- package/ccx/codex/__init__.py +0 -0
- package/ccx/codex/attach_tty.py +267 -0
- package/ccx/codex/client.py +732 -0
- package/ccx/codex/completion.py +121 -0
- package/ccx/codex/control.py +288 -0
- package/ccx/codex/daemon/__init__.py +0 -0
- package/ccx/codex/daemon/__main__.py +4 -0
- package/ccx/codex/daemon/daemon.py +1556 -0
- package/ccx/codex/daemon/models.py +140 -0
- package/ccx/codex/daemon/native.py +1590 -0
- package/ccx/codex/daemon/release.py +367 -0
- package/ccx/codex/daemon/worker.py +2396 -0
- package/ccx/codex/delivery.py +758 -0
- package/ccx/codex/delivery_recovery.py +232 -0
- package/ccx/codex/evidence.py +827 -0
- package/ccx/codex/history.py +158 -0
- package/ccx/codex/notify_history.py +140 -0
- package/ccx/codex/ops.py +666 -0
- package/ccx/codex/paths.py +70 -0
- package/ccx/codex/protocol.py +805 -0
- package/ccx/codex/provenance.py +105 -0
- package/ccx/codex/read.py +29 -0
- package/ccx/codex/records.py +51 -0
- package/ccx/codex/reports.py +177 -0
- package/ccx/codex/runtime.py +1412 -0
- package/ccx/codex/settle.py +363 -0
- package/ccx/codex/telemetry.py +792 -0
- package/ccx/codex/types.py +659 -0
- package/ccx/codex/worker_environment.py +25 -0
- package/ccx/constants.py +288 -0
- package/ccx/controllers/__init__.py +0 -0
- package/ccx/controllers/controller.py +1449 -0
- package/ccx/controllers/live_vars.py +1706 -0
- package/ccx/controllers/live_vars_runner.py +216 -0
- package/ccx/controllers/notify.py +213 -0
- package/ccx/controllers/orchestrator.py +193 -0
- package/ccx/controllers/policy.py +57 -0
- package/ccx/controllers/store.py +681 -0
- package/ccx/errors.py +634 -0
- package/ccx/notify/__init__.py +0 -0
- package/ccx/notify/__main__.py +5 -0
- package/ccx/notify/client.py +116 -0
- package/ccx/notify/daemon.py +480 -0
- package/ccx/notify/deliver.py +304 -0
- package/ccx/notify/paths.py +51 -0
- package/ccx/notify/registry.py +649 -0
- package/ccx/notify/select.py +750 -0
- package/ccx/notify/settle.py +684 -0
- package/ccx/ops/__init__.py +0 -0
- package/ccx/ops/background.py +115 -0
- package/ccx/ops/cancel.py +289 -0
- package/ccx/ops/codex_import.py +96 -0
- package/ccx/ops/common.py +397 -0
- package/ccx/ops/compact.py +337 -0
- package/ccx/ops/compact_ops.py +550 -0
- package/ccx/ops/context_events.py +48 -0
- package/ccx/ops/conversation.py +1540 -0
- package/ccx/ops/conversation_paging.py +1338 -0
- package/ccx/ops/create.py +56 -0
- package/ccx/ops/creation.py +952 -0
- package/ccx/ops/creation_steps.py +579 -0
- package/ccx/ops/event_feed.py +710 -0
- package/ccx/ops/forking.py +711 -0
- package/ccx/ops/input.py +556 -0
- package/ccx/ops/maintenance.py +295 -0
- package/ccx/ops/report.py +770 -0
- package/ccx/ops/semantics.py +490 -0
- package/ccx/ops/send.py +961 -0
- package/ccx/ops/steer_ops.py +867 -0
- package/ccx/ops/terminate.py +383 -0
- package/ccx/ops/turn_reports.py +306 -0
- package/ccx/ops/wait.py +762 -0
- package/ccx/ops/worker_events.py +636 -0
- package/ccx/rpc/__init__.py +0 -0
- package/ccx/rpc/local_rpc.py +1602 -0
- package/ccx/rpc/rpc.py +559 -0
- package/ccx/rpc/serve.py +374 -0
- package/ccx/rpc/transport.py +409 -0
- package/ccx/rpc/view.py +125 -0
- package/ccx/store/__init__.py +0 -0
- package/ccx/store/discovery.py +170 -0
- package/ccx/store/identity.py +105 -0
- package/ccx/store/journal_io.py +79 -0
- package/ccx/store/liveness.py +194 -0
- package/ccx/store/paths.py +254 -0
- package/ccx/store/record_cache.py +40 -0
- package/ccx/store/records.py +309 -0
- package/ccx/store/root.py +804 -0
- package/ccx/store/runtime_presence.py +137 -0
- package/ccx/store/store.py +2332 -0
- package/ccx/store/worker_state.py +86 -0
- package/ccx/system/__init__.py +0 -0
- package/ccx/system/bounded.py +296 -0
- package/ccx/system/cwd_paths.py +25 -0
- package/ccx/system/engine_binary.py +59 -0
- package/ccx/system/executable.py +50 -0
- package/ccx/system/oslayer.py +667 -0
- package/ccx/system/pipes.py +46 -0
- package/ccx/system/sanitize.py +43 -0
- package/ccx/types.py +705 -0
- package/ccx/version.py +29 -0
- package/ccx/workers.py +2081 -0
- package/ccx.py +9 -0
- package/package.json +40 -4
- package/pyproject.toml +87 -0
- package/skills/ccx/SKILL.md +155 -0
- package/skills/ccx/references/controllers.md +244 -0
- package/skills/ccx/references/troubleshooting.md +94 -0
- package/skills/ccx/references/variables.md +62 -0
|
@@ -0,0 +1,837 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from email.utils import parsedate_to_datetime
|
|
6
|
+
import fcntl
|
|
7
|
+
import errno
|
|
8
|
+
import math
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import stat
|
|
14
|
+
import tempfile
|
|
15
|
+
import time
|
|
16
|
+
import urllib.error
|
|
17
|
+
import urllib.parse
|
|
18
|
+
import urllib.request
|
|
19
|
+
|
|
20
|
+
from ccx.system import oslayer
|
|
21
|
+
from ccx.claude.oauth import oauth_headers, oauth_organization_uuid, oauth_token
|
|
22
|
+
from ccx.constants import (
|
|
23
|
+
CAP_METRICS,
|
|
24
|
+
CLAUDE_HOME,
|
|
25
|
+
usage_source_path,
|
|
26
|
+
USAGE_MIN_FETCH_INTERVAL_S,
|
|
27
|
+
OAUTH_USAGE_MIN_FETCH_INTERVAL_S,
|
|
28
|
+
OAUTH_USAGE_STALE_LIMIT_S,
|
|
29
|
+
UNOFFICIAL_RESET_ENV,
|
|
30
|
+
CAP_POLL_INTERVAL_S,
|
|
31
|
+
usage_cache_path,
|
|
32
|
+
USAGE_STALE_LIMIT_S,
|
|
33
|
+
USAGE_URL,
|
|
34
|
+
opt_in_enabled,
|
|
35
|
+
)
|
|
36
|
+
from ccx.errors import InvalidRequest
|
|
37
|
+
|
|
38
|
+
_SESSION_RESET_PROGRAM = "juniper_tide"
|
|
39
|
+
_SESSION_RESET_RESULTS = frozenset({
|
|
40
|
+
"reset", "already_used", "not_limited", "ineligible", "unavailable",
|
|
41
|
+
})
|
|
42
|
+
_SESSION_RESET_REASONS = frozenset({
|
|
43
|
+
"tier", "tenure", "surface", "mobile", "cli_version", "not_at_wall",
|
|
44
|
+
"weekly_limit", "no_weekly_limit", "other_experiment", "extra_usage",
|
|
45
|
+
"unavailable", "unknown",
|
|
46
|
+
})
|
|
47
|
+
_SESSION_RESET_ARMS = frozenset({"control", "reset"})
|
|
48
|
+
_SESSION_RESET_TIMEOUT_S = 25.0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# --- machine-wide Claude plan usage source and shared cache -----------------
|
|
52
|
+
|
|
53
|
+
_SOURCE_CHOICES = frozenset({"get_usage", "oauth"})
|
|
54
|
+
_RETRY_ERROR_S = 120.0
|
|
55
|
+
_RETRY_HELD_INITIAL_S = 300.0
|
|
56
|
+
_RETRY_HELD_MAX_S = 1800.0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _open_private_file(path: Path) -> int:
|
|
60
|
+
"""Do not trust a symlink, another owner's file, or a readable private record."""
|
|
61
|
+
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
62
|
+
st = os.fstat(fd)
|
|
63
|
+
if not stat.S_ISREG(st.st_mode) or st.st_uid != os.getuid() or st.st_mode & 0o077:
|
|
64
|
+
os.close(fd)
|
|
65
|
+
raise PermissionError(f"not a private regular file owned by this user: {path}")
|
|
66
|
+
return fd
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _read_private_json(path: Path) -> dict | None:
|
|
70
|
+
with os.fdopen(_open_private_file(path), "rb") as handle:
|
|
71
|
+
value = json.load(handle)
|
|
72
|
+
return value if isinstance(value, dict) else None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _open_usage_cache() -> int:
|
|
76
|
+
return _open_private_file(usage_cache_path())
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _read_usage_cache() -> dict | None:
|
|
80
|
+
try:
|
|
81
|
+
return _read_private_json(usage_cache_path())
|
|
82
|
+
except (OSError, ValueError, UnicodeError):
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _utc_timestamp(epoch: float) -> str:
|
|
87
|
+
return datetime.fromtimestamp(epoch, tz=timezone.utc).isoformat().replace("+00:00", "Z")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _parse_epoch(value: object) -> float | None:
|
|
91
|
+
if not isinstance(value, str):
|
|
92
|
+
return None
|
|
93
|
+
try:
|
|
94
|
+
result = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
95
|
+
if result.tzinfo is None:
|
|
96
|
+
return None
|
|
97
|
+
return result.timestamp()
|
|
98
|
+
except (ValueError, OverflowError):
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _cache_age(data: dict | None = None) -> float | None:
|
|
103
|
+
"""Age of the last successful provider sample, not the metadata file mtime."""
|
|
104
|
+
if data is None:
|
|
105
|
+
data = _read_usage_cache()
|
|
106
|
+
epoch = _parse_epoch((data.get("_ccx") or {}).get("fetchedAt")) if isinstance(data, dict) else None
|
|
107
|
+
return max(0.0, time.time() - epoch) if epoch is not None else None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _atomic_private_json(path: Path, value: dict) -> None:
|
|
111
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
112
|
+
tmp = None
|
|
113
|
+
try:
|
|
114
|
+
fd, tmp = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
115
|
+
with os.fdopen(fd, "w", encoding="utf-8") as out:
|
|
116
|
+
json.dump(value, out, separators=(",", ":"), allow_nan=False)
|
|
117
|
+
out.flush()
|
|
118
|
+
os.fsync(out.fileno())
|
|
119
|
+
# Never overwrite a planted symlink/foreign file at the final path.
|
|
120
|
+
try:
|
|
121
|
+
existing = _open_private_file(path)
|
|
122
|
+
except FileNotFoundError:
|
|
123
|
+
pass
|
|
124
|
+
except OSError as exc:
|
|
125
|
+
# Replacing a planted *symlink itself* is safe: os.replace never
|
|
126
|
+
# follows the destination. Its target is neither read nor written.
|
|
127
|
+
if exc.errno != errno.ELOOP:
|
|
128
|
+
raise
|
|
129
|
+
else:
|
|
130
|
+
os.close(existing)
|
|
131
|
+
os.replace(tmp, path)
|
|
132
|
+
tmp = None
|
|
133
|
+
finally:
|
|
134
|
+
if tmp is not None:
|
|
135
|
+
os.unlink(tmp)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _write_usage_cache(data: dict) -> None:
|
|
139
|
+
_atomic_private_json(usage_cache_path(), data)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _usage_source() -> str:
|
|
143
|
+
"""Persistent per-CCX_ROOT selector, read on every use (including live hosts)."""
|
|
144
|
+
try:
|
|
145
|
+
record = _read_private_json(usage_source_path())
|
|
146
|
+
except FileNotFoundError:
|
|
147
|
+
return "get_usage"
|
|
148
|
+
except (OSError, ValueError, UnicodeError) as exc:
|
|
149
|
+
raise InvalidRequest("invalid private Claude usage-source setting") from exc
|
|
150
|
+
if not isinstance(record, dict) or record.get("version") != 1 or record.get("source") not in _SOURCE_CHOICES:
|
|
151
|
+
raise InvalidRequest("invalid Claude usage-source setting; choose get_usage or oauth")
|
|
152
|
+
if record["source"] == "oauth" and not oslayer.direct_oauth_usage_supported():
|
|
153
|
+
raise InvalidRequest("Claude OAuth usage source is Linux-only; select get_usage")
|
|
154
|
+
return record["source"]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _lock_file_path() -> Path:
|
|
158
|
+
return Path(str(usage_cache_path()) + ".lock")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@contextmanager
|
|
162
|
+
def _usage_lock(timeout: float):
|
|
163
|
+
"""Serialize source switches and refreshes across independent CCX processes."""
|
|
164
|
+
path = _lock_file_path()
|
|
165
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
166
|
+
fd = os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600)
|
|
167
|
+
try:
|
|
168
|
+
st = os.fstat(fd)
|
|
169
|
+
if not stat.S_ISREG(st.st_mode) or st.st_uid != os.getuid() or st.st_mode & 0o077:
|
|
170
|
+
raise PermissionError("usage cache lock must be private and owned by this user")
|
|
171
|
+
deadline = time.monotonic() + max(0.0, timeout)
|
|
172
|
+
while True:
|
|
173
|
+
try:
|
|
174
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
175
|
+
break
|
|
176
|
+
except BlockingIOError:
|
|
177
|
+
if time.monotonic() >= deadline:
|
|
178
|
+
raise TimeoutError("Claude usage cache lock busy")
|
|
179
|
+
time.sleep(min(0.025, max(0.0, deadline - time.monotonic())))
|
|
180
|
+
try:
|
|
181
|
+
yield
|
|
182
|
+
finally:
|
|
183
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
184
|
+
finally:
|
|
185
|
+
os.close(fd)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def set_usage_source(source: str) -> str:
|
|
189
|
+
"""Explicit persistent selector update, without a provider read."""
|
|
190
|
+
if source not in _SOURCE_CHOICES:
|
|
191
|
+
raise InvalidRequest("usage source must be get_usage or oauth")
|
|
192
|
+
if source == "oauth" and not oslayer.direct_oauth_usage_supported():
|
|
193
|
+
raise InvalidRequest("direct OAuth usage is Linux-only; use get_usage")
|
|
194
|
+
try:
|
|
195
|
+
with _usage_lock(8.0):
|
|
196
|
+
try:
|
|
197
|
+
previous = _usage_source()
|
|
198
|
+
except InvalidRequest:
|
|
199
|
+
# The atomic writer still checks ownership and private-file mode.
|
|
200
|
+
previous = None
|
|
201
|
+
if previous != source or not usage_source_path().exists():
|
|
202
|
+
_atomic_private_json(usage_source_path(), {"version": 1, "source": source})
|
|
203
|
+
# Retire values from the previous source; never relabel them.
|
|
204
|
+
_write_usage_cache({"_ccx": _new_metadata(source)})
|
|
205
|
+
except (OSError, TimeoutError) as exc:
|
|
206
|
+
raise InvalidRequest(f"cannot set Claude usage source: {exc}") from exc
|
|
207
|
+
return source
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
_USAGE_RESPONSE_KEYS = frozenset({
|
|
211
|
+
"five_hour", "seven_day", "seven_day_opus", "seven_day_sonnet",
|
|
212
|
+
"limits", "model_scoped", "extra_usage", _SESSION_RESET_PROGRAM,
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _is_usage_payload(value: object) -> bool:
|
|
217
|
+
return isinstance(value, dict) and any(key in value for key in _USAGE_RESPONSE_KEYS)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _usage_request_url(*, at_wall: bool, skip_spend: bool = False) -> str:
|
|
221
|
+
if not at_wall:
|
|
222
|
+
return USAGE_URL
|
|
223
|
+
query = {"at_wall": 1}
|
|
224
|
+
if skip_spend:
|
|
225
|
+
query["skip_spend"] = 1
|
|
226
|
+
return f"{USAGE_URL}?{urllib.parse.urlencode(query)}"
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _oauth_sample(timeout: float = 8.0, *, at_wall: bool = True,
|
|
230
|
+
skip_spend: bool = False) -> dict:
|
|
231
|
+
"""The only read-only path that accesses the private OAuth identity."""
|
|
232
|
+
token = oauth_token()
|
|
233
|
+
if not token:
|
|
234
|
+
return {"kind": "error", "error": "no Claude OAuth token (log in to Claude Code)"}
|
|
235
|
+
request = urllib.request.Request(
|
|
236
|
+
_usage_request_url(at_wall=at_wall, skip_spend=skip_spend),
|
|
237
|
+
headers=oauth_headers(token),
|
|
238
|
+
)
|
|
239
|
+
try:
|
|
240
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
241
|
+
if not 200 <= response.status < 300:
|
|
242
|
+
return {"kind": "error", "error": f"usage endpoint returned HTTP {response.status}"}
|
|
243
|
+
body = json.loads(response.read(2 * 1024 * 1024 + 1).decode())
|
|
244
|
+
except urllib.error.HTTPError as exc:
|
|
245
|
+
if exc.code == 429:
|
|
246
|
+
retry_at = None
|
|
247
|
+
headers = exc.headers
|
|
248
|
+
if headers is not None:
|
|
249
|
+
value = headers.get("Retry-After")
|
|
250
|
+
if value:
|
|
251
|
+
try:
|
|
252
|
+
retry_at = time.time() + max(0.0, float(value))
|
|
253
|
+
except ValueError:
|
|
254
|
+
try:
|
|
255
|
+
retry_at = parsedate_to_datetime(value).timestamp()
|
|
256
|
+
except (ValueError, TypeError, OverflowError):
|
|
257
|
+
pass
|
|
258
|
+
return {"kind": "held", "error": "usage endpoint returned HTTP 429",
|
|
259
|
+
"retry_at": retry_at}
|
|
260
|
+
return {"kind": "error", "error": f"usage endpoint returned HTTP {exc.code}"}
|
|
261
|
+
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
262
|
+
return {"kind": "error", "error": f"usage endpoint unreachable: {exc.reason if isinstance(exc, urllib.error.URLError) else type(exc).__name__}"}
|
|
263
|
+
except (ValueError, UnicodeError):
|
|
264
|
+
return {"kind": "error", "error": "usage endpoint returned unreadable JSON"}
|
|
265
|
+
if _is_usage_payload(body):
|
|
266
|
+
return {"kind": "success", "body": body}
|
|
267
|
+
return {"kind": "unparsed", "raw": body,
|
|
268
|
+
"error": "usage endpoint returned an unrecognized payload"}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _request_usage_live_detail(
|
|
272
|
+
timeout: float = 8.0, *, at_wall: bool = True, skip_spend: bool = False,
|
|
273
|
+
) -> tuple[dict | None, str | None]:
|
|
274
|
+
"""Private direct-OAuth read, kept separate for reset's authoritative preflight."""
|
|
275
|
+
result = _oauth_sample(timeout, at_wall=at_wall, skip_spend=skip_spend)
|
|
276
|
+
return (result.get("body"), None) if result["kind"] == "success" else (None, result["error"])
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _request_usage_live(
|
|
280
|
+
timeout: float = 8.0, *, at_wall: bool = True, skip_spend: bool = False,
|
|
281
|
+
) -> dict | None:
|
|
282
|
+
return _request_usage_live_detail(timeout, at_wall=at_wall, skip_spend=skip_spend)[0]
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _new_metadata(source: str) -> dict:
|
|
286
|
+
"""Only the owner-approved persisted CCX metadata fields."""
|
|
287
|
+
return {"version": 1, "source": source, "fetchedAt": None,
|
|
288
|
+
"lastAttemptAt": None, "nextAttemptAt": None, "held": False,
|
|
289
|
+
"heldUntil": None, "heldUntilEstimated": False, "unparsed": False,
|
|
290
|
+
"raw": None, "error": None}
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _matching_cache(source: str) -> dict | None:
|
|
294
|
+
data = _read_usage_cache()
|
|
295
|
+
meta = data.get("_ccx") if isinstance(data, dict) else None
|
|
296
|
+
if isinstance(meta, dict) and meta.get("version") == 1 and meta.get("source") == source:
|
|
297
|
+
return data
|
|
298
|
+
return None
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _is_stale(data: dict | None, stale_limit: float = USAGE_STALE_LIMIT_S) -> bool:
|
|
302
|
+
if not isinstance(data, dict):
|
|
303
|
+
return True
|
|
304
|
+
meta = data.get("_ccx") or {}
|
|
305
|
+
if not isinstance(meta, dict) or meta.get("held") or meta.get("unparsed") or meta.get("error"):
|
|
306
|
+
return True
|
|
307
|
+
if meta.get("source") == "oauth":
|
|
308
|
+
stale_limit = max(stale_limit, OAUTH_USAGE_STALE_LIMIT_S)
|
|
309
|
+
age = _cache_age(data)
|
|
310
|
+
return age is None or age > stale_limit
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _refresh_due(data: dict | None, *, force: bool, max_age: float, now: float) -> bool:
|
|
314
|
+
if isinstance(data, dict):
|
|
315
|
+
meta = data.get("_ccx") or {}
|
|
316
|
+
deadline = _parse_epoch(meta.get("nextAttemptAt"))
|
|
317
|
+
if deadline is not None and now < deadline:
|
|
318
|
+
return False
|
|
319
|
+
if not force:
|
|
320
|
+
age = _cache_age(data)
|
|
321
|
+
if age is not None and age < max_age and not meta.get("held") and not meta.get("error") and not meta.get("unparsed"):
|
|
322
|
+
return False
|
|
323
|
+
return True
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _next_held_interval(old_meta: dict) -> float:
|
|
327
|
+
if old_meta.get("held"):
|
|
328
|
+
start = _parse_epoch(old_meta.get("lastAttemptAt"))
|
|
329
|
+
end = _parse_epoch(old_meta.get("heldUntil"))
|
|
330
|
+
if start is not None and end is not None and end > start:
|
|
331
|
+
return min(_RETRY_HELD_MAX_S, max(_RETRY_HELD_INITIAL_S, (end - start) * 2))
|
|
332
|
+
return _RETRY_HELD_INITIAL_S
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _apply_sample(source: str, prior: dict | None, sample: dict, now: float) -> dict:
|
|
336
|
+
"""Last-known provider values are retained; failed attempts never freshen them."""
|
|
337
|
+
old_meta = (prior or {}).get("_ccx") or {}
|
|
338
|
+
meta = _new_metadata(source)
|
|
339
|
+
meta["fetchedAt"] = old_meta.get("fetchedAt")
|
|
340
|
+
meta["lastAttemptAt"] = _utc_timestamp(now)
|
|
341
|
+
kind = sample["kind"]
|
|
342
|
+
if kind == "success" and (
|
|
343
|
+
not _is_usage_payload(sample.get("body")) or "_ccx" in sample["body"]
|
|
344
|
+
):
|
|
345
|
+
sample = {"kind": "unparsed", "raw": sample.get("body"),
|
|
346
|
+
"error": "unrecognized Claude usage response"}
|
|
347
|
+
kind = "unparsed"
|
|
348
|
+
if kind == "success":
|
|
349
|
+
body = dict(sample["body"])
|
|
350
|
+
meta["fetchedAt"] = _utc_timestamp(now)
|
|
351
|
+
floor = OAUTH_USAGE_MIN_FETCH_INTERVAL_S if source == "oauth" else USAGE_MIN_FETCH_INTERVAL_S
|
|
352
|
+
meta["nextAttemptAt"] = _utc_timestamp(now + floor)
|
|
353
|
+
else:
|
|
354
|
+
body = {k: v for k, v in (prior or {}).items() if k != "_ccx"}
|
|
355
|
+
meta["error"] = sample.get("error") or "Claude usage unavailable"
|
|
356
|
+
if kind == "held":
|
|
357
|
+
interval = _next_held_interval(old_meta)
|
|
358
|
+
provider_retry = sample.get("retry_at")
|
|
359
|
+
valid_retry = isinstance(provider_retry, (int, float)) and now < provider_retry <= now + _RETRY_HELD_MAX_S
|
|
360
|
+
until = provider_retry if valid_retry else now + interval
|
|
361
|
+
meta["held"] = True
|
|
362
|
+
meta["heldUntil"] = _utc_timestamp(until)
|
|
363
|
+
meta["heldUntilEstimated"] = not valid_retry
|
|
364
|
+
meta["nextAttemptAt"] = meta["heldUntil"]
|
|
365
|
+
else:
|
|
366
|
+
delay = 300.0 if kind == "unavailable" else _RETRY_ERROR_S
|
|
367
|
+
meta["nextAttemptAt"] = _utc_timestamp(now + delay)
|
|
368
|
+
if kind == "unparsed":
|
|
369
|
+
meta["unparsed"] = True
|
|
370
|
+
meta["raw"] = sample.get("raw")
|
|
371
|
+
body["_ccx"] = meta
|
|
372
|
+
return body
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _origin(data: dict | None, source: str | None, live: bool,
|
|
376
|
+
error: str | None = None) -> dict:
|
|
377
|
+
meta = (data or {}).get("_ccx") or {}
|
|
378
|
+
age = _cache_age(data)
|
|
379
|
+
return {"source": "live" if live else "cache" if data is not None else None,
|
|
380
|
+
"provider": source, "age_s": age,
|
|
381
|
+
"fetched_at": meta.get("fetchedAt"),
|
|
382
|
+
"held": bool(meta.get("held")), "held_until": meta.get("heldUntil"),
|
|
383
|
+
"held_until_estimated": bool(meta.get("heldUntilEstimated")),
|
|
384
|
+
"stale": _is_stale(data), "unparsed": bool(meta.get("unparsed")),
|
|
385
|
+
"next_attempt_at": meta.get("nextAttemptAt"),
|
|
386
|
+
"live_error": error or meta.get("error"), "cache": str(usage_cache_path())}
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _fetch_usage_common(*, timeout: float, max_age: float, force: bool) -> tuple[dict | None, dict]:
|
|
390
|
+
try:
|
|
391
|
+
source = _usage_source()
|
|
392
|
+
except InvalidRequest as exc:
|
|
393
|
+
return None, _origin(None, None, False, str(exc))
|
|
394
|
+
cached = _matching_cache(source)
|
|
395
|
+
now = time.time()
|
|
396
|
+
if not _refresh_due(cached, force=force, max_age=max_age, now=now):
|
|
397
|
+
return cached, _origin(cached, source, False)
|
|
398
|
+
began = time.monotonic()
|
|
399
|
+
try:
|
|
400
|
+
with _usage_lock(timeout):
|
|
401
|
+
# A different process might have fetched or changed the source.
|
|
402
|
+
source = _usage_source()
|
|
403
|
+
cached = _matching_cache(source)
|
|
404
|
+
now = time.time()
|
|
405
|
+
if not _refresh_due(cached, force=force, max_age=max_age, now=now):
|
|
406
|
+
return cached, _origin(cached, source, False)
|
|
407
|
+
left = max(0.0, timeout - (time.monotonic() - began))
|
|
408
|
+
if left <= 0:
|
|
409
|
+
return cached, _origin(cached, source, False, "Claude usage fetch deadline exceeded")
|
|
410
|
+
if source == "oauth":
|
|
411
|
+
sample = _oauth_sample(left)
|
|
412
|
+
else:
|
|
413
|
+
from ccx.claude.usage_control import request_usage
|
|
414
|
+
sample = request_usage(left)
|
|
415
|
+
fresh = _apply_sample(source, cached, sample, time.time())
|
|
416
|
+
_write_usage_cache(fresh)
|
|
417
|
+
return fresh, _origin(fresh, source, sample["kind"] == "success")
|
|
418
|
+
except (OSError, TimeoutError, InvalidRequest) as exc:
|
|
419
|
+
return cached, _origin(cached, source, False, f"Claude usage fetch failed: {exc}")
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def fetch_usage(timeout: float = 8.0, max_age: float = CAP_POLL_INTERVAL_S,
|
|
423
|
+
force: bool = False, stale_limit: float | None = None) -> dict | None:
|
|
424
|
+
"""One source-aware, process-shared cached Claude account usage read.
|
|
425
|
+
|
|
426
|
+
force ignores the caller's age preference, never the 60s global floor or
|
|
427
|
+
provider hold/backoff. stale_limit additionally fails open for caps.
|
|
428
|
+
"""
|
|
429
|
+
data, _ = _fetch_usage_common(timeout=8.0 if timeout is None else max(0.0, timeout),
|
|
430
|
+
max_age=max(0.0, max_age), force=force)
|
|
431
|
+
if data is None or (stale_limit is not None and _is_stale(data, stale_limit)):
|
|
432
|
+
return None
|
|
433
|
+
if not _is_usage_payload(data) and not (data.get("_ccx") or {}).get("unparsed"):
|
|
434
|
+
return None
|
|
435
|
+
return data
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def fetch_usage_for_display(
|
|
439
|
+
*, force: bool, timeout: float = 8.0, max_age: float = CAP_POLL_INTERVAL_S,
|
|
440
|
+
) -> tuple[dict | None, dict]:
|
|
441
|
+
data, origin = _fetch_usage_common(timeout=max(0.0, timeout),
|
|
442
|
+
max_age=max(0.0, max_age), force=force)
|
|
443
|
+
if data is not None and not _is_usage_payload(data) and not origin["unparsed"]:
|
|
444
|
+
return None, origin
|
|
445
|
+
return data, origin
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def describe_usage_age(age_s: float | None) -> str:
|
|
449
|
+
if age_s is None:
|
|
450
|
+
return "age unknown"
|
|
451
|
+
if age_s < 90:
|
|
452
|
+
return f"{max(0, int(age_s))}s old"
|
|
453
|
+
if age_s < 90 * 60:
|
|
454
|
+
return f"{int(age_s // 60)}m old"
|
|
455
|
+
return f"{int(age_s // 3600)}h old"
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _strict_provider_time(value: object) -> str | None:
|
|
459
|
+
if value is None:
|
|
460
|
+
return None
|
|
461
|
+
if not isinstance(value, str) or not value.strip():
|
|
462
|
+
raise ValueError("provider time must be a non-empty ISO string or null")
|
|
463
|
+
try:
|
|
464
|
+
parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
|
|
465
|
+
except ValueError as exc:
|
|
466
|
+
raise ValueError("invalid provider time") from exc
|
|
467
|
+
if parsed.tzinfo is None:
|
|
468
|
+
raise ValueError("provider time must include a timezone")
|
|
469
|
+
return value
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _parse_session_reset_status(data: dict | None) -> dict | None:
|
|
473
|
+
"""Strictly parse Anthropic's private session-reset status, else fail closed."""
|
|
474
|
+
if not isinstance(data, dict):
|
|
475
|
+
return None
|
|
476
|
+
block = data.get(_SESSION_RESET_PROGRAM)
|
|
477
|
+
if not isinstance(block, dict):
|
|
478
|
+
return None
|
|
479
|
+
eligible = block.get("eligible")
|
|
480
|
+
available = block.get("available")
|
|
481
|
+
if not isinstance(eligible, bool) or not isinstance(available, bool):
|
|
482
|
+
return None
|
|
483
|
+
reason = block.get("ineligible_reason")
|
|
484
|
+
if reason is not None and (
|
|
485
|
+
not isinstance(reason, str) or reason not in _SESSION_RESET_REASONS
|
|
486
|
+
):
|
|
487
|
+
return None
|
|
488
|
+
arm = block.get("arm")
|
|
489
|
+
if arm is not None and (not isinstance(arm, str) or arm not in _SESSION_RESET_ARMS):
|
|
490
|
+
return None
|
|
491
|
+
in_experiment = block.get("in_experiment")
|
|
492
|
+
if in_experiment is not None and not isinstance(in_experiment, bool):
|
|
493
|
+
return None
|
|
494
|
+
resets_per_week = block.get("resets_per_week")
|
|
495
|
+
if resets_per_week is not None and (
|
|
496
|
+
isinstance(resets_per_week, bool)
|
|
497
|
+
or not isinstance(resets_per_week, (int, float))
|
|
498
|
+
or not float(resets_per_week).is_integer()
|
|
499
|
+
or resets_per_week < 0
|
|
500
|
+
):
|
|
501
|
+
return None
|
|
502
|
+
try:
|
|
503
|
+
next_available_at = _strict_provider_time(block.get("next_available_at"))
|
|
504
|
+
weekly_resets_at = _strict_provider_time(block.get("weekly_resets_at"))
|
|
505
|
+
except ValueError:
|
|
506
|
+
return None
|
|
507
|
+
return {
|
|
508
|
+
"eligible": eligible,
|
|
509
|
+
"available": available,
|
|
510
|
+
"claimable": bool(eligible and available and arm == "reset"),
|
|
511
|
+
"ineligible_reason": reason,
|
|
512
|
+
"next_available_at": next_available_at,
|
|
513
|
+
"weekly_resets_at": weekly_resets_at,
|
|
514
|
+
"resets_per_week": int(resets_per_week) if resets_per_week is not None else None,
|
|
515
|
+
# Private protocol detail used only inside ccx's mutation gate.
|
|
516
|
+
"_arm": arm,
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def session_reset_status(data: dict | None) -> dict | None:
|
|
521
|
+
"""Stable public projection of the private provider reset entitlement."""
|
|
522
|
+
parsed = _parse_session_reset_status(data)
|
|
523
|
+
if parsed is None:
|
|
524
|
+
return None
|
|
525
|
+
return {k: v for k, v in parsed.items() if not k.startswith("_")}
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _parse_reset_result(value: object) -> dict | None:
|
|
529
|
+
if not isinstance(value, dict):
|
|
530
|
+
return None
|
|
531
|
+
result = value.get("result")
|
|
532
|
+
if not isinstance(result, str) or result not in _SESSION_RESET_RESULTS:
|
|
533
|
+
return None
|
|
534
|
+
try:
|
|
535
|
+
next_available_at = _strict_provider_time(value.get("next_available_at"))
|
|
536
|
+
weekly_resets_at = _strict_provider_time(value.get("weekly_resets_at"))
|
|
537
|
+
except ValueError:
|
|
538
|
+
return None
|
|
539
|
+
return {
|
|
540
|
+
"result": result,
|
|
541
|
+
"next_available_at": next_available_at,
|
|
542
|
+
"weekly_resets_at": weekly_resets_at,
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def reset_five_hour_limit(*, timeout: float = _SESSION_RESET_TIMEOUT_S) -> dict:
|
|
547
|
+
"""Claim Anthropic's provider-reported weekly 5-hour reset exactly once.
|
|
548
|
+
|
|
549
|
+
Eligibility is re-read live immediately before the mutation. A stale cache,
|
|
550
|
+
the locally displayed 5-hour percentage, or a locally calculated date can
|
|
551
|
+
never authorize this call. The mutation itself is never retried. After any
|
|
552
|
+
attempted mutation, one read-only usage fetch reconciles current state; an
|
|
553
|
+
ambiguous mutation error stays an error even if that reconciliation succeeds.
|
|
554
|
+
|
|
555
|
+
This claims Claude Code's own reset offer (a limited rollout) through its
|
|
556
|
+
account endpoint, which isn't a public API; disabled without
|
|
557
|
+
CCX_ENABLE_UNOFFICIAL_RESET=1. The opt-in check precedes all network I/O.
|
|
558
|
+
"""
|
|
559
|
+
if not opt_in_enabled(UNOFFICIAL_RESET_ENV):
|
|
560
|
+
raise InvalidRequest(
|
|
561
|
+
"reset_five_hour_limit: this claims Claude Code's five-hour reset "
|
|
562
|
+
"offer through its account endpoint, which isn't a public API and can "
|
|
563
|
+
f"change without notice; it is disabled by default. Set {UNOFFICIAL_RESET_ENV}=1 to "
|
|
564
|
+
"opt in."
|
|
565
|
+
)
|
|
566
|
+
if _usage_source() != "oauth":
|
|
567
|
+
raise InvalidRequest(
|
|
568
|
+
"reset_five_hour_limit: also select the machine-wide oauth usage "
|
|
569
|
+
"source with ccx usage --engine claude --set-source oauth"
|
|
570
|
+
)
|
|
571
|
+
preflight = _request_usage_live(
|
|
572
|
+
timeout=min(8.0, timeout), at_wall=True, skip_spend=True
|
|
573
|
+
)
|
|
574
|
+
parsed_status = _parse_session_reset_status(preflight)
|
|
575
|
+
public_status = session_reset_status(preflight)
|
|
576
|
+
if parsed_status is None or not parsed_status["claimable"]:
|
|
577
|
+
return {
|
|
578
|
+
"result": "not_available",
|
|
579
|
+
"status": public_status,
|
|
580
|
+
"refreshed": False,
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
token = oauth_token()
|
|
584
|
+
organization = oauth_organization_uuid()
|
|
585
|
+
if not token or not organization:
|
|
586
|
+
return {"result": "auth_error", "status": public_status, "refreshed": False}
|
|
587
|
+
|
|
588
|
+
encoded_org = urllib.parse.quote(organization, safe="")
|
|
589
|
+
url = f"https://api.anthropic.com/api/organizations/{encoded_org}/reset_rate_limits"
|
|
590
|
+
body = json.dumps({"program": _SESSION_RESET_PROGRAM}, separators=(",", ":")).encode()
|
|
591
|
+
req = urllib.request.Request(
|
|
592
|
+
url,
|
|
593
|
+
data=body,
|
|
594
|
+
method="POST",
|
|
595
|
+
headers=oauth_headers(token),
|
|
596
|
+
)
|
|
597
|
+
outcome: dict
|
|
598
|
+
try:
|
|
599
|
+
with urllib.request.urlopen(req, timeout=timeout) as response:
|
|
600
|
+
if not (200 <= response.status < 300):
|
|
601
|
+
outcome = {"result": "error"}
|
|
602
|
+
else:
|
|
603
|
+
try:
|
|
604
|
+
decoded = json.loads(response.read().decode())
|
|
605
|
+
except ValueError:
|
|
606
|
+
decoded = None
|
|
607
|
+
outcome = _parse_reset_result(decoded) or {"result": "error"}
|
|
608
|
+
except urllib.error.HTTPError as exc:
|
|
609
|
+
if exc.code == 429:
|
|
610
|
+
outcome = {"result": "rate_limited"}
|
|
611
|
+
elif exc.code in (401, 403):
|
|
612
|
+
outcome = {"result": "auth_error"}
|
|
613
|
+
else:
|
|
614
|
+
outcome = {"result": "error"}
|
|
615
|
+
except (OSError, urllib.error.URLError, TimeoutError):
|
|
616
|
+
outcome = {"result": "error"}
|
|
617
|
+
|
|
618
|
+
# Reconcile with one safe read. This is not a mutation retry and never
|
|
619
|
+
# upgrades an ambiguous POST failure into a guessed success.
|
|
620
|
+
refreshed = _request_usage_live(
|
|
621
|
+
timeout=min(8.0, timeout), at_wall=True, skip_spend=False
|
|
622
|
+
)
|
|
623
|
+
outcome["status"] = (
|
|
624
|
+
session_reset_status(refreshed) if refreshed is not None else public_status
|
|
625
|
+
)
|
|
626
|
+
outcome["refreshed"] = refreshed is not None
|
|
627
|
+
return outcome
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def read_usage_cached(stale_limit: float | None = None) -> dict | None:
|
|
631
|
+
"""Nonblocking, cache-only source-aware read for the host's select loop."""
|
|
632
|
+
try:
|
|
633
|
+
data = _matching_cache(_usage_source())
|
|
634
|
+
except InvalidRequest:
|
|
635
|
+
return None
|
|
636
|
+
if data is None or (stale_limit is not None and _is_stale(data, stale_limit)):
|
|
637
|
+
return None
|
|
638
|
+
return data if _is_usage_payload(data) else None
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _valid_pct(value: object) -> float | None:
|
|
642
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
643
|
+
return None
|
|
644
|
+
number = float(value)
|
|
645
|
+
return number if math.isfinite(number) and 0.0 <= number <= 100.0 else None
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _metric_util(data: dict, key: str) -> float | None:
|
|
649
|
+
"""A missing/null/invalid metric is unknown, never an assumed zero."""
|
|
650
|
+
field = CAP_METRICS.get(key)
|
|
651
|
+
if field is None or not data:
|
|
652
|
+
return None
|
|
653
|
+
block = data.get(field)
|
|
654
|
+
util = _valid_pct(block.get("utilization")) if isinstance(block, dict) else None
|
|
655
|
+
if util is not None:
|
|
656
|
+
return util
|
|
657
|
+
if key == "opus":
|
|
658
|
+
for quota in scoped_weekly_limits(data):
|
|
659
|
+
if quota["model"].casefold() == "opus":
|
|
660
|
+
return quota["utilization"]
|
|
661
|
+
return None
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def unknown_cap_metrics(data: dict | None, caps: dict | None) -> list[str]:
|
|
665
|
+
"""Explain fail-open omissions without exposing an arbitrary private payload."""
|
|
666
|
+
if not caps:
|
|
667
|
+
return []
|
|
668
|
+
if data is None or (isinstance(data.get("_ccx"), dict) and _is_stale(data)):
|
|
669
|
+
return [key for key, limit in caps.items() if limit is not None]
|
|
670
|
+
return [key for key, limit in caps.items()
|
|
671
|
+
if limit is not None and _metric_util(data, key) is None]
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def cap_tripped(data: dict | None, caps: dict | None):
|
|
675
|
+
"""Return first known exceeded cap; held/stale/unknown signals fail open."""
|
|
676
|
+
if not data or not caps:
|
|
677
|
+
return None
|
|
678
|
+
if isinstance(data.get("_ccx"), dict) and _is_stale(data):
|
|
679
|
+
return None
|
|
680
|
+
for key, cap in caps.items():
|
|
681
|
+
if cap is None:
|
|
682
|
+
continue
|
|
683
|
+
util = _metric_util(data, key)
|
|
684
|
+
if util is not None and util >= float(cap):
|
|
685
|
+
return (key, util, float(cap))
|
|
686
|
+
return None
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
_SUMMARY_QUOTA_FIELDS = ("five_hour", "seven_day")
|
|
690
|
+
_LEGACY_MODEL_FIELDS = ("seven_day_opus", "seven_day_sonnet")
|
|
691
|
+
|
|
692
|
+
def _summary_block(block) -> dict | None:
|
|
693
|
+
if block is None:
|
|
694
|
+
return None
|
|
695
|
+
if not isinstance(block, dict):
|
|
696
|
+
return None
|
|
697
|
+
out = {}
|
|
698
|
+
if "utilization" in block:
|
|
699
|
+
util = block.get("utilization")
|
|
700
|
+
validated = _valid_pct(util)
|
|
701
|
+
if validated is not None:
|
|
702
|
+
out["utilization"] = validated
|
|
703
|
+
resets_at = block.get("resets_at")
|
|
704
|
+
if _parse_epoch(resets_at) is not None:
|
|
705
|
+
out["resets_at"] = resets_at
|
|
706
|
+
return out or None
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def scoped_weekly_limits(data: dict) -> list[dict]:
|
|
710
|
+
"""Normalize the currently relevant per-model weekly quota catalog.
|
|
711
|
+
|
|
712
|
+
The ``limits`` catalog describes model-specific quotas. Consumers follow
|
|
713
|
+
that catalog so their UI does not depend on a fixed set of model names.
|
|
714
|
+
"""
|
|
715
|
+
out = []
|
|
716
|
+
limits = data.get("limits") if isinstance(data, dict) else None
|
|
717
|
+
for item in limits if isinstance(limits, list) else ():
|
|
718
|
+
if not isinstance(item, dict) or item.get("kind") != "weekly_scoped":
|
|
719
|
+
continue
|
|
720
|
+
scope = item.get("scope")
|
|
721
|
+
model = scope.get("model") if isinstance(scope, dict) else None
|
|
722
|
+
name = model.get("display_name") if isinstance(model, dict) else None
|
|
723
|
+
percent = _valid_pct(item.get("percent"))
|
|
724
|
+
if not isinstance(name, str) or not name or percent is None:
|
|
725
|
+
continue
|
|
726
|
+
quota = {
|
|
727
|
+
"model": name,
|
|
728
|
+
"utilization": percent,
|
|
729
|
+
}
|
|
730
|
+
model_id = model.get("id")
|
|
731
|
+
if isinstance(model_id, str) and model_id:
|
|
732
|
+
quota["model_id"] = model_id
|
|
733
|
+
resets_at = item.get("resets_at")
|
|
734
|
+
if _parse_epoch(resets_at) is not None:
|
|
735
|
+
quota["resets_at"] = resets_at
|
|
736
|
+
if isinstance(item.get("is_active"), bool):
|
|
737
|
+
quota["is_active"] = item["is_active"]
|
|
738
|
+
out.append(quota)
|
|
739
|
+
# The control reply's model_scoped[] is additive. Never synthesize limits[]
|
|
740
|
+
# in the cache; only add missing models to the in-memory presentation.
|
|
741
|
+
scoped = data.get("model_scoped") if isinstance(data, dict) else None
|
|
742
|
+
seen_names = {item["model"].casefold() for item in out}
|
|
743
|
+
if isinstance(scoped, list):
|
|
744
|
+
for item in scoped:
|
|
745
|
+
if not isinstance(item, dict):
|
|
746
|
+
continue
|
|
747
|
+
name, util = item.get("display_name"), _valid_pct(item.get("utilization"))
|
|
748
|
+
if not isinstance(name, str) or not name or util is None or name.casefold() in seen_names:
|
|
749
|
+
continue
|
|
750
|
+
quota = {"model": name, "utilization": util}
|
|
751
|
+
if _parse_epoch(item.get("resets_at")) is not None:
|
|
752
|
+
quota["resets_at"] = item["resets_at"]
|
|
753
|
+
out.append(quota)
|
|
754
|
+
seen_names.add(name.casefold())
|
|
755
|
+
return out
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def usage_summary(data: dict) -> dict:
|
|
759
|
+
"""Compact public quota summary: stable actionable fields only.
|
|
760
|
+
|
|
761
|
+
Includes five_hour, seven_day, provider-announced model_quotas, and
|
|
762
|
+
extra_usage (when enabled). Non-null legacy per-model fields are retained
|
|
763
|
+
for cached payload compatibility. Omits all other raw API keys."""
|
|
764
|
+
meta = data.get("_ccx") or {}
|
|
765
|
+
if isinstance(meta, dict) and meta.get("unparsed"):
|
|
766
|
+
return {"unparsed": True, "raw": meta.get("raw"), "error": meta.get("error")}
|
|
767
|
+
out = {}
|
|
768
|
+
for key in _SUMMARY_QUOTA_FIELDS:
|
|
769
|
+
if key not in data:
|
|
770
|
+
continue
|
|
771
|
+
block = _summary_block(data.get(key))
|
|
772
|
+
if block is not None:
|
|
773
|
+
out[key] = block
|
|
774
|
+
model_quotas = scoped_weekly_limits(data)
|
|
775
|
+
if model_quotas:
|
|
776
|
+
out["model_quotas"] = model_quotas
|
|
777
|
+
else:
|
|
778
|
+
# Old caches may predate the dynamic limits catalog. Preserve actual
|
|
779
|
+
# legacy quota blocks, but omit null retired fields rather than showing
|
|
780
|
+
# misleading Opus/Sonnet 0% gauges.
|
|
781
|
+
for key in _LEGACY_MODEL_FIELDS:
|
|
782
|
+
block = data.get(key)
|
|
783
|
+
if not isinstance(block, dict):
|
|
784
|
+
continue
|
|
785
|
+
summary = _summary_block(block)
|
|
786
|
+
if summary is not None:
|
|
787
|
+
out[key] = summary
|
|
788
|
+
xu = data.get("extra_usage")
|
|
789
|
+
if isinstance(xu, dict) and xu.get("is_enabled"):
|
|
790
|
+
extra = {"enabled": True}
|
|
791
|
+
for key in ("utilization", "used_credits", "monthly_limit", "currency"):
|
|
792
|
+
value = xu.get(key)
|
|
793
|
+
if value is not None:
|
|
794
|
+
extra[key] = value
|
|
795
|
+
out["extra_usage"] = extra
|
|
796
|
+
return out
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def _pct(s: str) -> float:
|
|
800
|
+
"""argparse type for a 0–100 utilization percentage."""
|
|
801
|
+
try:
|
|
802
|
+
v = float(s)
|
|
803
|
+
except ValueError:
|
|
804
|
+
raise argparse.ArgumentTypeError(f"{s!r} is not a number")
|
|
805
|
+
if not (0.0 <= v <= 100.0):
|
|
806
|
+
raise argparse.ArgumentTypeError(f"cap must be between 0 and 100 (got {v})")
|
|
807
|
+
return v
|
|
808
|
+
|
|
809
|
+
def _add_cap_flags(p):
|
|
810
|
+
p.add_argument("--cap-5h", type=_pct, default=None, metavar="PCT",
|
|
811
|
+
help="Refuse to start when 5-hour usage is already at PCT%% (0–100), and stop "
|
|
812
|
+
"the turn if it reaches PCT%% while running (exit 7).")
|
|
813
|
+
p.add_argument("--cap-7d", type=_pct, default=None, metavar="PCT",
|
|
814
|
+
help="The same for 7-day usage.")
|
|
815
|
+
p.add_argument("--cap-opus", type=_pct, default=None, metavar="PCT",
|
|
816
|
+
help="Claude only: the same for 7-day Opus usage.")
|
|
817
|
+
|
|
818
|
+
def _caps_from_args(args) -> dict | None:
|
|
819
|
+
caps = {}
|
|
820
|
+
for k, attr in (("5h", "cap_5h"), ("7d", "cap_7d"), ("opus", "cap_opus")):
|
|
821
|
+
v = getattr(args, attr, None)
|
|
822
|
+
if v is not None:
|
|
823
|
+
caps[k] = v
|
|
824
|
+
return caps or None
|
|
825
|
+
|
|
826
|
+
def _merge_caps(caps_list) -> dict | None:
|
|
827
|
+
"""Combine the caps of several sends that batch into one turn — strictest wins
|
|
828
|
+
(lowest threshold per metric, union of metrics)."""
|
|
829
|
+
merged = {}
|
|
830
|
+
for c in caps_list:
|
|
831
|
+
if not c:
|
|
832
|
+
continue
|
|
833
|
+
for k, v in c.items():
|
|
834
|
+
if v is None:
|
|
835
|
+
continue
|
|
836
|
+
merged[k] = min(v, merged[k]) if k in merged else v
|
|
837
|
+
return merged or None
|