cdx-manager 0.20.0 → 0.20.2
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/README.md +1 -1
- package/VERSION +1 -1
- package/changelogs/CHANGELOGS_0_20_1.md +6 -0
- package/changelogs/CHANGELOGS_0_20_2.md +12 -0
- package/checksums/release-archives.json +22 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/claude_usage.py +33 -0
- package/src/cli_args.py +10 -6
- package/src/commands/status.py +3 -1
- package/src/interactive_usage.py +5 -2
- package/src/repair.py +18 -0
- package/src/session_service.py +71 -0
- package/src/session_store.py +32 -0
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
<br clear="left"/>
|
|
6
6
|
|
|
7
|
-
[](LICENSE) ](LICENSE)  
|
|
8
8
|
|
|
9
9
|
**Stop guessing which AI account still has quota.** `cdx` tracks the rate-limit window of every Codex and Claude account you own, tells you which one is usable right now, and launches it with isolated auth — in one command.
|
|
10
10
|
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.20.
|
|
1
|
+
0.20.2
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# CDX Manager 0.20.1
|
|
2
|
+
|
|
3
|
+
## Fixes
|
|
4
|
+
|
|
5
|
+
- `cdx status --refresh` no longer reports a false "OAuth access token has expired" alert for a profile that is still logged in. The persisted access token's `expiresAt` is now checked offline before the quota probe runs; once it is known to be expired, the probe is skipped instead of reproducing a predictable 401 (`claude auth status` does not refresh it — only a real Claude Code invocation does). (#11)
|
|
6
|
+
- The stale-quota/local-auth-valid warning path now also recognizes the provider's current `OAuth access token has expired` message, not just the older `invalid authentication credentials` phrasing.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# CDX Manager 0.20.2
|
|
2
|
+
|
|
3
|
+
## Fixes
|
|
4
|
+
|
|
5
|
+
- Claude transcripts up to 128 MiB are now measured. Before each relaunch, CDX
|
|
6
|
+
reconciles the preceding Claude conversation when its exact transcript was
|
|
7
|
+
not captured; `cdx repair --force` also repairs safely attributable history.
|
|
8
|
+
- Token pricing now ignores synthetic transcript model placeholders when a real
|
|
9
|
+
provider model is present, so measured Claude sessions receive their price.
|
|
10
|
+
- `cdx history` and `cdx stats --since` now include sessions that overlap the
|
|
11
|
+
requested period, including sessions that started before the window and
|
|
12
|
+
completed inside it.
|
|
@@ -309,6 +309,28 @@
|
|
|
309
309
|
"x86_64-pc-windows-msvc": "c5a29140bb9d634f2533084084ca10cb90ca64d34cac1b664998d305b934a7fb",
|
|
310
310
|
"x86_64-unknown-linux-musl": "d1850f3acdede7c2cdcd31b5ca6d5e2372535a32bf75c0e6c8fd0100af7813f2"
|
|
311
311
|
}
|
|
312
|
+
},
|
|
313
|
+
"v0.20.0": {
|
|
314
|
+
"github_tarball_sha256": "8222afa4f8e5fef0291e180450c1b24fb623a6543104f62bd430d27b2157c75b",
|
|
315
|
+
"github_zip_sha256": "c09ab617e1af92bf7c3690fe8d21e497cb772ea270eb8b83ea413b1718d978cd",
|
|
316
|
+
"tray_assets": {
|
|
317
|
+
"aarch64-apple-darwin": "8b6d93b6d67d2b0119c544beca251b5dcaede56b770fc8bed741c36a2f2d868a",
|
|
318
|
+
"aarch64-unknown-linux-musl": "f4bb1bd9a8980f403a7d5af9724dfae4b63345323019e212fe257c4689f7480d",
|
|
319
|
+
"x86_64-apple-darwin": "1e90758238f87c73bf1dadf7d9c3efe2967ab6f65b484d9fd7ed4e3cceae671f",
|
|
320
|
+
"x86_64-pc-windows-msvc": "e88d296e706ae90624a1f82bf14d36d67787d5f73bbb04986c1ca82293178c68",
|
|
321
|
+
"x86_64-unknown-linux-musl": "cf8ec3f5a56bf50ba42d2dfa44f05ecc4471c5a5a819ca8b5180c6d9eff75ff1"
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
"v0.20.1": {
|
|
325
|
+
"github_tarball_sha256": "00922589fbfa5edaee8bff2b9328bd185e91551eb63932fd7e0642a2fd101923",
|
|
326
|
+
"github_zip_sha256": "16aa1e4376368454769150b3adfec146dde5c88ddef8a157c343c00025ed58ce",
|
|
327
|
+
"tray_assets": {
|
|
328
|
+
"aarch64-apple-darwin": "4f73559113b77a67612e47be3cb3d81bfd9cee7b99fd8b5257f01ea6ec1b9d36",
|
|
329
|
+
"aarch64-unknown-linux-musl": "27f3bd4a6c03d164340a753387ac522d43ce0523f70f0251484b2d24a8e504df",
|
|
330
|
+
"x86_64-apple-darwin": "6a11eee823a76e00f02ab1c75c247b079f1e264351f467c41f4aeeb46bc9a3c3",
|
|
331
|
+
"x86_64-pc-windows-msvc": "3513680e96fd5f669c2f2868b3a195899e48ed4d5f3dce080289f90648e60199",
|
|
332
|
+
"x86_64-unknown-linux-musl": "ee86688537638671ceec1d805d23124d8511c11ab62836f14fa2545b418741d2"
|
|
333
|
+
}
|
|
312
334
|
}
|
|
313
335
|
}
|
|
314
336
|
}
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
package/src/claude_usage.py
CHANGED
|
@@ -129,6 +129,32 @@ def _parse_reset_epoch(value):
|
|
|
129
129
|
return parsed.timestamp()
|
|
130
130
|
|
|
131
131
|
|
|
132
|
+
CLAUDE_ACCESS_TOKEN_EXPIRY_SKEW_SECONDS = 60
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _current_epoch_seconds():
|
|
136
|
+
return datetime.now(timezone.utc).timestamp()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _parse_epoch_seconds(value):
|
|
140
|
+
if value in (None, ""):
|
|
141
|
+
return None
|
|
142
|
+
try:
|
|
143
|
+
num = float(value)
|
|
144
|
+
except (TypeError, ValueError):
|
|
145
|
+
return _parse_reset_epoch(value)
|
|
146
|
+
# Claude CLI persists expiresAt in milliseconds since epoch.
|
|
147
|
+
return num / 1000 if num > 1e12 else num
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _access_token_expired(creds, now=None):
|
|
151
|
+
expires_at = _parse_epoch_seconds(creds.get("expiresAt")) if isinstance(creds, dict) else None
|
|
152
|
+
if expires_at is None:
|
|
153
|
+
return False
|
|
154
|
+
now = _current_epoch_seconds() if now is None else now
|
|
155
|
+
return now >= expires_at - CLAUDE_ACCESS_TOKEN_EXPIRY_SKEW_SECONDS
|
|
156
|
+
|
|
157
|
+
|
|
132
158
|
def _safe_float(value):
|
|
133
159
|
if value is None:
|
|
134
160
|
return None
|
|
@@ -241,4 +267,11 @@ def refresh_claude_session_status(session, auth_refresher=None):
|
|
|
241
267
|
creds = _read_claude_credentials(auth_home)
|
|
242
268
|
if not creds or not creds.get("accessToken"):
|
|
243
269
|
return None
|
|
270
|
+
if _access_token_expired(creds):
|
|
271
|
+
# ponytail: `claude auth status` doesn't refresh an expired access
|
|
272
|
+
# token (verified no-op) and only a real Claude Code invocation does.
|
|
273
|
+
# Probing anyway just reproduces a 401 we can already predict
|
|
274
|
+
# offline, so skip it like the "no credentials" case above and leave
|
|
275
|
+
# the cached quota as stale rather than raising a false alert.
|
|
276
|
+
return None
|
|
244
277
|
return fetch_claude_rate_limit_headers(creds["accessToken"])
|
package/src/cli_args.py
CHANGED
|
@@ -745,8 +745,8 @@ def _has_history_period(period):
|
|
|
745
745
|
return period.get("from_ts") is not None or period.get("to_ts") is not None
|
|
746
746
|
|
|
747
747
|
|
|
748
|
-
def _parse_entry_timestamp(entry):
|
|
749
|
-
value = entry.get(
|
|
748
|
+
def _parse_entry_timestamp(entry, field="started_at"):
|
|
749
|
+
value = entry.get(field)
|
|
750
750
|
if not value:
|
|
751
751
|
return None
|
|
752
752
|
text = str(value)
|
|
@@ -768,12 +768,16 @@ def _filter_history_period(entries, period):
|
|
|
768
768
|
start = period.get("from_ts")
|
|
769
769
|
end = period.get("to_ts")
|
|
770
770
|
for entry in entries:
|
|
771
|
-
|
|
772
|
-
if
|
|
771
|
+
started = _parse_entry_timestamp(entry)
|
|
772
|
+
if started is None:
|
|
773
773
|
continue
|
|
774
|
-
|
|
774
|
+
# A long run belongs to a period it overlaps, not only the day it was
|
|
775
|
+
# launched. Usage is recorded per run, so include its whole measured
|
|
776
|
+
# total rather than inventing a time-proportional split.
|
|
777
|
+
ended = _parse_entry_timestamp(entry, "ended_at") or started
|
|
778
|
+
if start is not None and ended < start:
|
|
775
779
|
continue
|
|
776
|
-
if end is not None and
|
|
780
|
+
if end is not None and started > end:
|
|
777
781
|
continue
|
|
778
782
|
filtered.append(entry)
|
|
779
783
|
return filtered
|
package/src/commands/status.py
CHANGED
|
@@ -790,7 +790,9 @@ def _rows_by_session(rows):
|
|
|
790
790
|
|
|
791
791
|
def _is_invalid_claude_usage_auth(error):
|
|
792
792
|
text = str(error or "").lower()
|
|
793
|
-
|
|
793
|
+
if "http 401" not in text:
|
|
794
|
+
return False
|
|
795
|
+
return "invalid authentication credentials" in text or "oauth access token has expired" in text
|
|
794
796
|
|
|
795
797
|
def _has_valid_local_claude_auth(item, rows_by_name):
|
|
796
798
|
session = item.get("session")
|
package/src/interactive_usage.py
CHANGED
|
@@ -22,7 +22,10 @@ import time
|
|
|
22
22
|
from .run_usage import claude_usage_dedup_key, normalize_usage
|
|
23
23
|
from .status_source import _normalize_terminal_transcript
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
# Readers stream JSONL line-by-line. A 16 MiB cap excluded ordinary long
|
|
26
|
+
# Claude sessions (for example a five-hour session at 26 MiB) without saving
|
|
27
|
+
# meaningful memory; keep a bounded ceiling for genuinely pathological files.
|
|
28
|
+
MAX_TRANSCRIPT_BYTES = 128 * 1024 * 1024
|
|
26
29
|
MAX_TRANSCRIPT_CANDIDATES = 1000
|
|
27
30
|
MAX_TRANSCRIPT_SCAN_SECONDS = 1
|
|
28
31
|
|
|
@@ -298,7 +301,7 @@ def _claude_usage(handle):
|
|
|
298
301
|
totals["cache_creation_tokens"] += _number(usage.get("cache_creation_input_tokens"))
|
|
299
302
|
totals["cache_read_tokens"] += _number(usage.get("cache_read_input_tokens"))
|
|
300
303
|
totals["output_tokens"] += _number(usage.get("output_tokens"))
|
|
301
|
-
if message.get("model"):
|
|
304
|
+
if isinstance(message.get("model"), str) and not message["model"].startswith("<"):
|
|
302
305
|
model = message["model"]
|
|
303
306
|
except (TypeError, ValueError):
|
|
304
307
|
continue
|
package/src/repair.py
CHANGED
|
@@ -51,6 +51,24 @@ def repair_health(service, base_dir, env=None, dry_run=True, force=False):
|
|
|
51
51
|
None,
|
|
52
52
|
None if dry_run else "applied",
|
|
53
53
|
))
|
|
54
|
+
backfill = service["backfill_interactive_usage"](dry_run=dry_run)
|
|
55
|
+
if backfill["backfilled"]:
|
|
56
|
+
actions.append(_action(
|
|
57
|
+
"backfill_interactive_usage",
|
|
58
|
+
f"backfill token usage for {backfill['backfilled']} exactly matched Claude launch record"
|
|
59
|
+
f"{'s' if backfill['backfilled'] != 1 else ''} (of {backfill['scanned']} scanned)",
|
|
60
|
+
None,
|
|
61
|
+
None if dry_run else "applied",
|
|
62
|
+
))
|
|
63
|
+
models = service["refresh_interactive_usage_models"](dry_run=dry_run)
|
|
64
|
+
if models["backfilled"]:
|
|
65
|
+
actions.append(_action(
|
|
66
|
+
"refresh_interactive_usage_models",
|
|
67
|
+
f"replace placeholder models on {models['backfilled']} launch record"
|
|
68
|
+
f"{'s' if models['backfilled'] != 1 else ''}",
|
|
69
|
+
None,
|
|
70
|
+
None if dry_run else "applied",
|
|
71
|
+
))
|
|
54
72
|
return {
|
|
55
73
|
"dry_run": dry_run,
|
|
56
74
|
"force": force,
|
package/src/session_service.py
CHANGED
|
@@ -727,6 +727,72 @@ def get_launch_history(store, name=None, limit=20):
|
|
|
727
727
|
return store["list_launch_history"](session_name=name, limit=limit)
|
|
728
728
|
|
|
729
729
|
|
|
730
|
+
def backfill_interactive_usage(store, name=None, dry_run=True):
|
|
731
|
+
"""Recover one safely attributable missing Claude launch per profile."""
|
|
732
|
+
from datetime import datetime
|
|
733
|
+
|
|
734
|
+
from .interactive_usage import MATCH_CONVERSATION_ID, extract_interactive_usage
|
|
735
|
+
|
|
736
|
+
updates = {}
|
|
737
|
+
sessions = [store["get_session"](name)] if name else store["list_sessions"]()
|
|
738
|
+
for session in sessions:
|
|
739
|
+
if not session:
|
|
740
|
+
continue
|
|
741
|
+
if session.get("provider") != PROVIDER_CLAUDE:
|
|
742
|
+
continue
|
|
743
|
+
conversation = session.get("conversation") or {}
|
|
744
|
+
identifier = conversation.get("id")
|
|
745
|
+
recorded = conversation.get("recordedAt")
|
|
746
|
+
if not identifier or not recorded:
|
|
747
|
+
continue
|
|
748
|
+
usage, path, match, model = extract_interactive_usage(
|
|
749
|
+
PROVIDER_CLAUDE, session.get("authHome"), conversation_id=identifier
|
|
750
|
+
)
|
|
751
|
+
if not usage or match != MATCH_CONVERSATION_ID:
|
|
752
|
+
continue
|
|
753
|
+
history = store["list_launch_history"](session_name=session["name"], limit=0)
|
|
754
|
+
target = next((entry for entry in history if not entry.get("usage") and entry.get("provider_transcript_path") in (None, path)), None)
|
|
755
|
+
if not target or not target.get("started_at"):
|
|
756
|
+
continue
|
|
757
|
+
try:
|
|
758
|
+
delta = abs((datetime.fromisoformat(recorded.replace("Z", "+00:00")) - datetime.fromisoformat(target["started_at"].replace("Z", "+00:00"))).total_seconds())
|
|
759
|
+
except ValueError:
|
|
760
|
+
continue
|
|
761
|
+
if delta > 300:
|
|
762
|
+
continue
|
|
763
|
+
updates[(session["name"], target["started_at"])] = {
|
|
764
|
+
"usage": usage,
|
|
765
|
+
"usage_cumulative": usage,
|
|
766
|
+
"usage_model": model,
|
|
767
|
+
"provider_transcript_path": path,
|
|
768
|
+
"provider_transcript_match": match,
|
|
769
|
+
"usage_backfilled_reason": "exact_current_conversation",
|
|
770
|
+
}
|
|
771
|
+
return store["backfill_launch_usage"](updates, dry_run=dry_run)
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def refresh_interactive_usage_models(store, dry_run=True):
|
|
775
|
+
"""Replace a placeholder model with the exact transcript's real model."""
|
|
776
|
+
from .interactive_usage import MATCH_CONVERSATION_ID, extract_interactive_usage
|
|
777
|
+
|
|
778
|
+
updates = {}
|
|
779
|
+
for session in store["list_sessions"]():
|
|
780
|
+
if session.get("provider") != PROVIDER_CLAUDE:
|
|
781
|
+
continue
|
|
782
|
+
identifier = ((session.get("conversation") or {}).get("id"))
|
|
783
|
+
if not identifier:
|
|
784
|
+
continue
|
|
785
|
+
_usage, path, match, model = extract_interactive_usage(
|
|
786
|
+
PROVIDER_CLAUDE, session.get("authHome"), conversation_id=identifier
|
|
787
|
+
)
|
|
788
|
+
if not path or match != MATCH_CONVERSATION_ID or not model or model.startswith("<"):
|
|
789
|
+
continue
|
|
790
|
+
for entry in store["list_launch_history"](session_name=session["name"], limit=0):
|
|
791
|
+
if entry.get("provider_transcript_path") == path and str(entry.get("usage_model") or "").startswith("<"):
|
|
792
|
+
updates[(session["name"], entry.get("started_at"))] = {"usage_model": model}
|
|
793
|
+
return store["backfill_launch_usage"](updates, dry_run=dry_run)
|
|
794
|
+
|
|
795
|
+
|
|
730
796
|
def start_session_runtime(store, name, payload=None):
|
|
731
797
|
session = store["get_session"](name)
|
|
732
798
|
if not session:
|
|
@@ -803,6 +869,9 @@ def launch_session(store, name, resume=False):
|
|
|
803
869
|
raise CdxError(f"Unknown session: {name}")
|
|
804
870
|
if session.get("enabled", True) is False:
|
|
805
871
|
raise CdxError(f"Session is disabled: {name}")
|
|
872
|
+
# Reconcile the prior Claude conversation before a fresh launch replaces
|
|
873
|
+
# its id. This is a single exact transcript lookup, not a global stats scan.
|
|
874
|
+
backfill_interactive_usage(store, name=name, dry_run=False)
|
|
806
875
|
now = _local_now_iso()
|
|
807
876
|
|
|
808
877
|
def state_updater(state):
|
|
@@ -921,6 +990,8 @@ def create_session_service(options=None):
|
|
|
921
990
|
"record_launch_history": partial(record_launch_history, store),
|
|
922
991
|
"get_launch_history": partial(get_launch_history, store),
|
|
923
992
|
"drop_unvouched_usage": store["drop_unvouched_usage"],
|
|
993
|
+
"backfill_interactive_usage": partial(backfill_interactive_usage, store),
|
|
994
|
+
"refresh_interactive_usage_models": partial(refresh_interactive_usage_models, store),
|
|
924
995
|
"update_auth_state": partial(update_auth_state, store),
|
|
925
996
|
"get_status_row": partial(get_status_row, store, base_dir, env, fetch_codex_status),
|
|
926
997
|
"get_status_rows": partial(get_status_rows, store, base_dir, env, fetch_codex_status),
|
package/src/session_store.py
CHANGED
|
@@ -333,6 +333,37 @@ def create_session_store(base_dir):
|
|
|
333
333
|
_fsync_directory(state_dir)
|
|
334
334
|
return {"scanned": scanned, "dropped": dropped, "dry_run": dry_run}
|
|
335
335
|
|
|
336
|
+
def backfill_launch_usage(updates, dry_run=True):
|
|
337
|
+
"""Apply pre-validated usage updates to otherwise empty history rows."""
|
|
338
|
+
with _file_lock(lock_file):
|
|
339
|
+
try:
|
|
340
|
+
with open(launch_history_file, encoding="utf-8") as handle:
|
|
341
|
+
lines = handle.readlines()
|
|
342
|
+
except FileNotFoundError:
|
|
343
|
+
return {"scanned": 0, "backfilled": 0, "dry_run": dry_run}
|
|
344
|
+
scanned = backfilled = 0
|
|
345
|
+
rewritten = []
|
|
346
|
+
for line in lines:
|
|
347
|
+
stripped = line.strip()
|
|
348
|
+
if not stripped:
|
|
349
|
+
continue
|
|
350
|
+
try:
|
|
351
|
+
entry = json.loads(stripped)
|
|
352
|
+
except ValueError:
|
|
353
|
+
rewritten.append(line)
|
|
354
|
+
continue
|
|
355
|
+
scanned += 1
|
|
356
|
+
update = updates.get((entry.get("session_name"), entry.get("started_at")))
|
|
357
|
+
if update and (not entry.get("usage") or str(entry.get("usage_model") or "").startswith("<")):
|
|
358
|
+
entry.update(update)
|
|
359
|
+
line = json.dumps(entry, separators=(",", ":")) + "\n"
|
|
360
|
+
backfilled += 1
|
|
361
|
+
rewritten.append(line)
|
|
362
|
+
if not dry_run and backfilled:
|
|
363
|
+
atomic_write(launch_history_file, "".join(rewritten))
|
|
364
|
+
_fsync_directory(state_dir)
|
|
365
|
+
return {"scanned": scanned, "backfilled": backfilled, "dry_run": dry_run}
|
|
366
|
+
|
|
336
367
|
def list_launch_history(session_name=None, limit=20):
|
|
337
368
|
with _file_lock(lock_file):
|
|
338
369
|
try:
|
|
@@ -369,5 +400,6 @@ def create_session_store(base_dir):
|
|
|
369
400
|
"update_session_state": update_session_state,
|
|
370
401
|
"append_launch_history": append_launch_history,
|
|
371
402
|
"drop_unvouched_usage": drop_unvouched_usage,
|
|
403
|
+
"backfill_launch_usage": backfill_launch_usage,
|
|
372
404
|
"list_launch_history": list_launch_history,
|
|
373
405
|
}
|