cdx-manager 0.10.0 → 0.11.1
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 +10 -8
- package/bin/cdx +2 -11
- package/changelogs/CHANGELOGS_0_11_0.md +51 -0
- package/changelogs/CHANGELOGS_0_11_1.md +34 -0
- package/checksums/release-archives.json +8 -0
- package/install.ps1 +12 -19
- package/install.sh +6 -29
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/claude_refresh.py +17 -33
- package/src/cli.py +9 -36
- package/src/cli_args.py +30 -12
- package/src/cli_commands.py +46 -17
- package/src/cli_helpers.py +26 -38
- package/src/cli_render.py +5 -0
- package/src/cli_view.py +5 -27
- package/src/codex_usage.py +14 -8
- package/src/notify.py +6 -10
- package/src/provider_runtime.py +58 -14
- package/src/session_service.py +174 -57
- package/src/session_store.py +2 -2
- package/src/status_source.py +47 -25
- package/src/status_view.py +11 -2
package/src/session_service.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import base64
|
|
2
|
+
import binascii
|
|
2
3
|
import json
|
|
3
4
|
import os
|
|
4
5
|
import shutil
|
|
@@ -21,6 +22,7 @@ from .status_source import find_latest_status_artifact
|
|
|
21
22
|
DEFAULT_PROVIDER = PROVIDER_CODEX
|
|
22
23
|
ALLOWED_PROVIDERS = set(PROVIDERS)
|
|
23
24
|
MAX_SESSION_NAME_LENGTH = 64
|
|
25
|
+
MAX_SESSION_LABEL_LENGTH = 64
|
|
24
26
|
RESERVED_SESSION_NAMES = {
|
|
25
27
|
"add",
|
|
26
28
|
"can-resume",
|
|
@@ -39,6 +41,7 @@ RESERVED_SESSION_NAMES = {
|
|
|
39
41
|
"history",
|
|
40
42
|
"import",
|
|
41
43
|
"last",
|
|
44
|
+
"label",
|
|
42
45
|
"login",
|
|
43
46
|
"logout",
|
|
44
47
|
"model",
|
|
@@ -533,10 +536,21 @@ def create_session_service(options=None):
|
|
|
533
536
|
if name in RESERVED_SESSION_NAMES:
|
|
534
537
|
raise CdxError(f"Session name is reserved: {name}")
|
|
535
538
|
|
|
539
|
+
def _normalize_session_label(label):
|
|
540
|
+
text = str(label or "").strip()
|
|
541
|
+
if not text:
|
|
542
|
+
raise CdxError("Session label is required")
|
|
543
|
+
if len(text) > MAX_SESSION_LABEL_LENGTH:
|
|
544
|
+
raise CdxError(f"Session label is too long (max {MAX_SESSION_LABEL_LENGTH} characters)")
|
|
545
|
+
if any(ord(ch) < 32 or ord(ch) == 127 for ch in text):
|
|
546
|
+
raise CdxError("Session label cannot contain control characters")
|
|
547
|
+
return text
|
|
548
|
+
|
|
536
549
|
def _build_export_session_record(session):
|
|
537
550
|
return {
|
|
538
551
|
"name": session["name"],
|
|
539
552
|
"provider": session["provider"],
|
|
553
|
+
"label": session.get("label"),
|
|
540
554
|
"enabled": session.get("enabled", True) is not False,
|
|
541
555
|
"createdAt": session.get("createdAt"),
|
|
542
556
|
"updatedAt": session.get("updatedAt"),
|
|
@@ -697,6 +711,7 @@ def create_session_service(options=None):
|
|
|
697
711
|
"lastStatusAt": None,
|
|
698
712
|
"lastStatus": None,
|
|
699
713
|
**({"launch": source.get("launch")} if source.get("launch") else {}),
|
|
714
|
+
**({"label": source.get("label")} if source.get("label") else {}),
|
|
700
715
|
"auth": {
|
|
701
716
|
"status": "unknown",
|
|
702
717
|
"lastCheckedAt": None,
|
|
@@ -887,6 +902,31 @@ def create_session_service(options=None):
|
|
|
887
902
|
"updatedAt": now,
|
|
888
903
|
})
|
|
889
904
|
|
|
905
|
+
def set_session_label(name, label):
|
|
906
|
+
session = store["get_session"](name)
|
|
907
|
+
if not session:
|
|
908
|
+
raise CdxError(f"Unknown session: {name}")
|
|
909
|
+
normalized = _normalize_session_label(label)
|
|
910
|
+
now = _local_now_iso()
|
|
911
|
+
return store["update_session"](name, lambda s: {
|
|
912
|
+
**s,
|
|
913
|
+
"label": normalized,
|
|
914
|
+
"updatedAt": now,
|
|
915
|
+
})
|
|
916
|
+
|
|
917
|
+
def clear_session_label(name):
|
|
918
|
+
session = store["get_session"](name)
|
|
919
|
+
if not session:
|
|
920
|
+
raise CdxError(f"Unknown session: {name}")
|
|
921
|
+
now = _local_now_iso()
|
|
922
|
+
|
|
923
|
+
def updater(s):
|
|
924
|
+
updated = {**s, "updatedAt": now}
|
|
925
|
+
updated.pop("label", None)
|
|
926
|
+
return updated
|
|
927
|
+
|
|
928
|
+
return store["update_session"](name, updater)
|
|
929
|
+
|
|
890
930
|
def set_launch_settings(name, settings):
|
|
891
931
|
session = store["get_session"](name)
|
|
892
932
|
if not session:
|
|
@@ -901,6 +941,11 @@ def create_session_service(options=None):
|
|
|
901
941
|
# lock; a pre-lock snapshot loses a concurrent setting change.
|
|
902
942
|
current = _normalize_launch_settings(s.get("launch") or {}, mark_fast_service_tier=False)
|
|
903
943
|
launch = {**current, **updates}
|
|
944
|
+
if "power" in updates:
|
|
945
|
+
launch.pop("reasoning_effort", None)
|
|
946
|
+
launch.pop("reasoningEffort", None)
|
|
947
|
+
if "reasoning_effort" in updates:
|
|
948
|
+
launch.pop("power", None)
|
|
904
949
|
explicit_power = "power" in updates or "reasoning_effort" in updates
|
|
905
950
|
if explicit_power and "fast" not in updates and launch.get("fastMode") != "service_tier":
|
|
906
951
|
launch["fast"] = False
|
|
@@ -923,7 +968,7 @@ def create_session_service(options=None):
|
|
|
923
968
|
raise CdxError(f"Unknown session: {name}")
|
|
924
969
|
if not keys:
|
|
925
970
|
raise CdxError("At least one launch setting is required.")
|
|
926
|
-
allowed = {"power", "permission", "fast", "rtk", "logics", "model", "priority"}
|
|
971
|
+
allowed = {"power", "reasoning_effort", "reasoningEffort", "permission", "fast", "rtk", "logics", "model", "priority"}
|
|
927
972
|
unknown = [key for key in keys if key not in allowed]
|
|
928
973
|
if unknown:
|
|
929
974
|
raise CdxError(f"Unsupported launch setting: {', '.join(unknown)}")
|
|
@@ -1100,6 +1145,7 @@ def create_session_service(options=None):
|
|
|
1100
1145
|
row_status = status if enabled else None
|
|
1101
1146
|
return {
|
|
1102
1147
|
"session_name": s["name"],
|
|
1148
|
+
"label": s.get("label"),
|
|
1103
1149
|
"provider": s["provider"],
|
|
1104
1150
|
"enabled": enabled,
|
|
1105
1151
|
"active": bool(_session_runtime(s["name"])) if enabled else False,
|
|
@@ -1216,7 +1262,12 @@ def create_session_service(options=None):
|
|
|
1216
1262
|
)] = s
|
|
1217
1263
|
for future in as_completed(futures):
|
|
1218
1264
|
s = futures[future]
|
|
1219
|
-
|
|
1265
|
+
try:
|
|
1266
|
+
resolved = future.result()
|
|
1267
|
+
except CdxError as error:
|
|
1268
|
+
if not str(error).startswith("Unknown session:"):
|
|
1269
|
+
raise
|
|
1270
|
+
continue
|
|
1220
1271
|
resolved_by_name[s["name"]] = resolved
|
|
1221
1272
|
if progress_callback:
|
|
1222
1273
|
progress_callback({
|
|
@@ -1225,7 +1276,7 @@ def create_session_service(options=None):
|
|
|
1225
1276
|
"has_status": bool(resolved.get("lastStatus")),
|
|
1226
1277
|
"cache_hit": cache_hits[s["name"]],
|
|
1227
1278
|
})
|
|
1228
|
-
resolved = [resolved_by_name[s["name"]] for s in sessions]
|
|
1279
|
+
resolved = [resolved_by_name[s["name"]] for s in sessions if s["name"] in resolved_by_name]
|
|
1229
1280
|
|
|
1230
1281
|
def sort_key(s):
|
|
1231
1282
|
at = s.get("lastStatusAt") or ""
|
|
@@ -1262,6 +1313,7 @@ def create_session_service(options=None):
|
|
|
1262
1313
|
)
|
|
1263
1314
|
return [{
|
|
1264
1315
|
"name": s["name"],
|
|
1316
|
+
"label": s.get("label"),
|
|
1265
1317
|
"provider": s["provider"] if has_multiple else None,
|
|
1266
1318
|
"enabled": s.get("enabled", True) is not False,
|
|
1267
1319
|
"active": bool(_session_runtime(s["name"])) if s.get("enabled", True) is not False else False,
|
|
@@ -1336,7 +1388,52 @@ def create_session_service(options=None):
|
|
|
1336
1388
|
"bundle_size_bytes": len(bundle_bytes),
|
|
1337
1389
|
}
|
|
1338
1390
|
|
|
1339
|
-
def
|
|
1391
|
+
def _validate_import_profile_files(payload, imported_sessions):
|
|
1392
|
+
profiles = payload.get("profiles") or {}
|
|
1393
|
+
if not isinstance(profiles, dict):
|
|
1394
|
+
raise CdxError("Bundle contains invalid profile data.")
|
|
1395
|
+
decoded_profiles = {}
|
|
1396
|
+
for session_payload in imported_sessions:
|
|
1397
|
+
name = session_payload["name"]
|
|
1398
|
+
files = []
|
|
1399
|
+
profile_items = profiles.get(name, [])
|
|
1400
|
+
if not isinstance(profile_items, list):
|
|
1401
|
+
raise CdxError(f"Bundle contains invalid profile data for session {name}.")
|
|
1402
|
+
for item in profile_items:
|
|
1403
|
+
if not isinstance(item, dict):
|
|
1404
|
+
raise CdxError(f"Bundle contains invalid profile entry for session {name}.")
|
|
1405
|
+
rel_path = _safe_relpath(item.get("path"))
|
|
1406
|
+
data_b64 = item.get("data_b64")
|
|
1407
|
+
if not isinstance(data_b64, str):
|
|
1408
|
+
raise CdxError(f"Bundle contains invalid file data for session {name}: {rel_path}")
|
|
1409
|
+
try:
|
|
1410
|
+
content = base64.b64decode(data_b64.encode("ascii"), validate=True)
|
|
1411
|
+
except (binascii.Error, UnicodeEncodeError) as error:
|
|
1412
|
+
raise CdxError(f"Bundle contains invalid file data for session {name}: {rel_path}") from error
|
|
1413
|
+
files.append({"path": rel_path, "content": content})
|
|
1414
|
+
decoded_profiles[name] = files
|
|
1415
|
+
return decoded_profiles
|
|
1416
|
+
|
|
1417
|
+
def _restore_import_backup(name, backup_root, session_root, old_record, old_state):
|
|
1418
|
+
if os.path.exists(session_root):
|
|
1419
|
+
remove_tree(session_root, ignore_errors=True)
|
|
1420
|
+
if backup_root and os.path.exists(backup_root):
|
|
1421
|
+
os.rename(backup_root, session_root)
|
|
1422
|
+
if old_record:
|
|
1423
|
+
store["replace_session"](name, old_record)
|
|
1424
|
+
if old_state is not None:
|
|
1425
|
+
store["write_session_state"](name, old_state)
|
|
1426
|
+
else:
|
|
1427
|
+
store["remove_session"](name)
|
|
1428
|
+
|
|
1429
|
+
def import_bundle(
|
|
1430
|
+
file_path,
|
|
1431
|
+
passphrase=None,
|
|
1432
|
+
session_names=None,
|
|
1433
|
+
force=False,
|
|
1434
|
+
merge=False,
|
|
1435
|
+
allow_authless_force=False,
|
|
1436
|
+
):
|
|
1340
1437
|
if not file_path or not os.path.isfile(file_path):
|
|
1341
1438
|
raise CdxError(f"Bundle file not found: {file_path}")
|
|
1342
1439
|
with open(file_path, "rb") as handle:
|
|
@@ -1358,73 +1455,91 @@ def create_session_service(options=None):
|
|
|
1358
1455
|
conflicts = [name for name in names if name in existing]
|
|
1359
1456
|
if conflicts and not force and not merge:
|
|
1360
1457
|
raise CdxError(f"Import would overwrite existing sessions: {', '.join(conflicts)}")
|
|
1458
|
+
if conflicts and force and not decoded["meta"].get("include_auth") and not allow_authless_force:
|
|
1459
|
+
raise CdxError(
|
|
1460
|
+
"Import --force would overwrite existing session credentials, but this bundle has no auth payloads. "
|
|
1461
|
+
"Re-export with --include-auth, use --merge, or pass --allow-authless-force to discard local credentials."
|
|
1462
|
+
)
|
|
1463
|
+
|
|
1464
|
+
for session_payload in imported_sessions:
|
|
1465
|
+
_validate_new_session_name(session_payload["name"])
|
|
1466
|
+
_normalize_provider(session_payload["provider"])
|
|
1467
|
+
decoded_profiles = _validate_import_profile_files(payload, imported_sessions)
|
|
1361
1468
|
|
|
1362
1469
|
for session_payload in imported_sessions:
|
|
1363
1470
|
name = session_payload["name"]
|
|
1364
|
-
_validate_new_session_name(name)
|
|
1365
1471
|
provider = _normalize_provider(session_payload["provider"])
|
|
1366
1472
|
is_existing = name in existing
|
|
1367
1473
|
|
|
1368
|
-
if is_existing and force:
|
|
1369
|
-
remove_session(name)
|
|
1370
|
-
is_existing = False
|
|
1371
|
-
|
|
1372
1474
|
session_root = _get_session_root(name)
|
|
1373
1475
|
auth_home = _get_session_auth_home(name, provider)
|
|
1374
1476
|
_ensure_private_dir(base_dir)
|
|
1375
1477
|
_ensure_private_dir(os.path.join(base_dir, "profiles"))
|
|
1478
|
+
backup_root = None
|
|
1479
|
+
old_record = None
|
|
1480
|
+
old_state = None
|
|
1481
|
+
if is_existing and force:
|
|
1482
|
+
old_record = store["get_session"](name)
|
|
1483
|
+
old_state = store["read_session_state"](name)
|
|
1484
|
+
if os.path.exists(session_root):
|
|
1485
|
+
backup_root = tempfile.mkdtemp(prefix=f".{_encode(name)}.import.", dir=os.path.dirname(session_root))
|
|
1486
|
+
os.rmdir(backup_root)
|
|
1487
|
+
os.rename(session_root, backup_root)
|
|
1488
|
+
is_existing = False
|
|
1376
1489
|
_ensure_private_dir(session_root)
|
|
1377
1490
|
_ensure_private_dir(auth_home)
|
|
1378
1491
|
|
|
1379
1492
|
existing_state_before = None
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1493
|
+
try:
|
|
1494
|
+
if is_existing and merge:
|
|
1495
|
+
existing_record = store["get_session"](name) or {}
|
|
1496
|
+
# replace_session resets the state file to defaults, so the
|
|
1497
|
+
# local state must be captured before it runs.
|
|
1498
|
+
existing_state_before = store["read_session_state"](name) or {}
|
|
1499
|
+
bundle_record = {
|
|
1500
|
+
**session_payload,
|
|
1501
|
+
"provider": provider,
|
|
1502
|
+
"enabled": session_payload.get("enabled", True) is not False,
|
|
1503
|
+
"sessionRoot": session_root,
|
|
1504
|
+
"authHome": auth_home,
|
|
1505
|
+
}
|
|
1506
|
+
# Existing values take precedence; bundle fills in missing keys only.
|
|
1507
|
+
merged_record = {**bundle_record, **{k: v for k, v in existing_record.items() if v is not None}}
|
|
1508
|
+
merged_record["sessionRoot"] = session_root
|
|
1509
|
+
merged_record["authHome"] = auth_home
|
|
1510
|
+
store["replace_session"](name, merged_record)
|
|
1511
|
+
else:
|
|
1512
|
+
session_record = {
|
|
1513
|
+
**session_payload,
|
|
1514
|
+
"provider": provider,
|
|
1515
|
+
"enabled": session_payload.get("enabled", True) is not False,
|
|
1516
|
+
"sessionRoot": session_root,
|
|
1517
|
+
"authHome": auth_home,
|
|
1518
|
+
}
|
|
1519
|
+
store["replace_session"](name, session_record)
|
|
1520
|
+
|
|
1521
|
+
state = (payload.get("states") or {}).get(name)
|
|
1522
|
+
if is_existing and merge:
|
|
1523
|
+
merged_state = {**(state or {}), **{k: v for k, v in (existing_state_before or {}).items() if v is not None}}
|
|
1524
|
+
if merged_state:
|
|
1525
|
+
store["write_session_state"](name, merged_state)
|
|
1526
|
+
elif state is not None:
|
|
1527
|
+
store["write_session_state"](name, state)
|
|
1528
|
+
|
|
1529
|
+
for item in decoded_profiles.get(name, []):
|
|
1530
|
+
dest_path = os.path.join(session_root, item["path"])
|
|
1531
|
+
# In merge mode, skip files that already exist locally.
|
|
1532
|
+
if is_existing and merge and os.path.exists(dest_path):
|
|
1533
|
+
continue
|
|
1534
|
+
_ensure_private_dir(os.path.dirname(dest_path))
|
|
1535
|
+
# Decrypted credentials: 0o600 from creation, no umask window.
|
|
1536
|
+
atomic_write(dest_path, item["content"], mode=0o600)
|
|
1537
|
+
except Exception:
|
|
1538
|
+
_restore_import_backup(name, backup_root, session_root, old_record, old_state)
|
|
1539
|
+
raise
|
|
1540
|
+
finally:
|
|
1541
|
+
if backup_root and os.path.exists(backup_root):
|
|
1542
|
+
remove_tree(backup_root, ignore_errors=True)
|
|
1428
1543
|
|
|
1429
1544
|
return {
|
|
1430
1545
|
"path": file_path,
|
|
@@ -1445,6 +1560,8 @@ def create_session_service(options=None):
|
|
|
1445
1560
|
"list_sessions": list_sessions,
|
|
1446
1561
|
"get_session": get_session,
|
|
1447
1562
|
"set_session_enabled": set_session_enabled,
|
|
1563
|
+
"set_session_label": set_session_label,
|
|
1564
|
+
"clear_session_label": clear_session_label,
|
|
1448
1565
|
"set_launch_settings": set_launch_settings,
|
|
1449
1566
|
"unset_launch_settings": unset_launch_settings,
|
|
1450
1567
|
"record_status": record_status,
|
package/src/session_store.py
CHANGED
|
@@ -294,8 +294,8 @@ def create_session_store(base_dir):
|
|
|
294
294
|
continue
|
|
295
295
|
try:
|
|
296
296
|
entry = json.loads(line)
|
|
297
|
-
except json.JSONDecodeError
|
|
298
|
-
|
|
297
|
+
except json.JSONDecodeError:
|
|
298
|
+
continue
|
|
299
299
|
if session_name and entry.get("session_name") != session_name:
|
|
300
300
|
continue
|
|
301
301
|
entries.append(entry)
|
package/src/status_source.py
CHANGED
|
@@ -20,10 +20,6 @@ _KEY_VALUE_PATTERNS = [
|
|
|
20
20
|
("remaining_5h_pct", re.compile(r"remaining_?5h_pct\s*[:=]\s*(\d{1,3})%?", re.I)),
|
|
21
21
|
("remaining_week_pct", re.compile(r"remaining_?week_pct\s*[:=]\s*(\d{1,3})%?", re.I)),
|
|
22
22
|
("credits", re.compile(r"credits?\s*[:=]\s*(\d[\d, ]*(?:\.\d+)?)\s*(?:credits?)?", re.I)),
|
|
23
|
-
("remaining_5h_pct", re.compile(r"5h\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})%\s*left", re.I)),
|
|
24
|
-
("remaining_week_pct", re.compile(r"weekly\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})%\s*left", re.I)),
|
|
25
|
-
("remaining_5h_pct", re.compile(r"5h\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})(?:%|\b)", re.I)),
|
|
26
|
-
("remaining_week_pct", re.compile(r"weekly\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})(?:%|\b)", re.I)),
|
|
27
23
|
("usage_pct", re.compile(r"usage\s*[:=]\s*(\d{1,3})%", re.I)),
|
|
28
24
|
("usage_pct", re.compile(r"current\s*[:=]\s*(\d{1,3})%", re.I)),
|
|
29
25
|
("remaining_5h_pct", re.compile(r"5h(?:\s+remaining)?\s*[:=]\s*(\d{1,3})%", re.I)),
|
|
@@ -32,6 +28,14 @@ _KEY_VALUE_PATTERNS = [
|
|
|
32
28
|
("remaining_week_pct", re.compile(r"remaining\s+week\s*[:=]\s*(\d{1,3})%", re.I)),
|
|
33
29
|
]
|
|
34
30
|
|
|
31
|
+
_CODEX_LIMIT_PERCENT_PATTERNS = [
|
|
32
|
+
("remaining_5h_pct", re.compile(r"5h\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})%\s*left", re.I)),
|
|
33
|
+
("remaining_5h_pct", re.compile(r"5h\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})(?:%|\b)", re.I)),
|
|
34
|
+
("remaining_week_pct", re.compile(r"weekly\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})%\s*left", re.I)),
|
|
35
|
+
("remaining_week_pct", re.compile(r"weekly\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})(?:%|\b)", re.I)),
|
|
36
|
+
]
|
|
37
|
+
_KEY_VALUE_PATTERNS.extend(_CODEX_LIMIT_PERCENT_PATTERNS)
|
|
38
|
+
|
|
35
39
|
|
|
36
40
|
def _strip_ansi(text):
|
|
37
41
|
return _ANSI_ESCAPE.sub("", str(text or ""))
|
|
@@ -103,6 +107,27 @@ def _is_zero_credit_balance(value):
|
|
|
103
107
|
return False
|
|
104
108
|
|
|
105
109
|
|
|
110
|
+
def _get_structured_rate_limit_window(rate_limits, duration_mins, fallback_key):
|
|
111
|
+
for key in ("primary", "secondary"):
|
|
112
|
+
window = rate_limits.get(key) or {}
|
|
113
|
+
if window.get("windowDurationMins") == duration_mins:
|
|
114
|
+
return window
|
|
115
|
+
if window.get("window_minutes") == duration_mins:
|
|
116
|
+
return window
|
|
117
|
+
fallback = rate_limits.get(fallback_key) or {}
|
|
118
|
+
if "windowDurationMins" in fallback or "window_minutes" in fallback:
|
|
119
|
+
return {}
|
|
120
|
+
return fallback
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _get_rate_limit_used_percent(window):
|
|
124
|
+
return window.get("used_percent", window.get("usedPercent"))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _get_rate_limit_reset_at(window):
|
|
128
|
+
return window.get("resets_at", window.get("resetsAt"))
|
|
129
|
+
|
|
130
|
+
|
|
106
131
|
def _extract_structured_rate_limits(record):
|
|
107
132
|
if not isinstance(record, dict):
|
|
108
133
|
return None
|
|
@@ -111,25 +136,27 @@ def _extract_structured_rate_limits(record):
|
|
|
111
136
|
if not isinstance(rate_limits, dict):
|
|
112
137
|
return None
|
|
113
138
|
|
|
114
|
-
|
|
115
|
-
|
|
139
|
+
five_hour = _get_structured_rate_limit_window(rate_limits, 300, "primary")
|
|
140
|
+
weekly = _get_structured_rate_limit_window(rate_limits, 10080, "secondary")
|
|
116
141
|
credits = rate_limits.get("credits") or {}
|
|
117
142
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
remaining_5h_pct = None if
|
|
121
|
-
remaining_week_pct = None if
|
|
143
|
+
five_hour_used = _coerce_percentage(_get_rate_limit_used_percent(five_hour))
|
|
144
|
+
weekly_used = _coerce_percentage(_get_rate_limit_used_percent(weekly))
|
|
145
|
+
remaining_5h_pct = None if five_hour_used is None else max(0, 100 - five_hour_used)
|
|
146
|
+
remaining_week_pct = None if weekly_used is None else max(0, 100 - weekly_used)
|
|
122
147
|
|
|
123
148
|
result = {
|
|
124
|
-
"usage_pct":
|
|
149
|
+
"usage_pct": five_hour_used if five_hour_used is not None else weekly_used,
|
|
125
150
|
"remaining_5h_pct": remaining_5h_pct,
|
|
126
151
|
"remaining_week_pct": remaining_week_pct,
|
|
127
152
|
"credits": None,
|
|
128
|
-
"reset_5h_at": _normalize_timestamp_value(
|
|
129
|
-
"reset_week_at": _normalize_timestamp_value(
|
|
153
|
+
"reset_5h_at": _normalize_timestamp_value(_get_rate_limit_reset_at(five_hour)),
|
|
154
|
+
"reset_week_at": _normalize_timestamp_value(_get_rate_limit_reset_at(weekly)),
|
|
130
155
|
"reset_at": None,
|
|
131
156
|
"raw_status_text": json.dumps(rate_limits, sort_keys=True),
|
|
132
157
|
}
|
|
158
|
+
if not five_hour and weekly:
|
|
159
|
+
result["_skip_5h_backfill"] = True
|
|
133
160
|
|
|
134
161
|
balance = credits.get("balance")
|
|
135
162
|
if balance not in (None, "") and not (
|
|
@@ -496,18 +523,10 @@ def extract_named_statuses_from_text(text):
|
|
|
496
523
|
break
|
|
497
524
|
|
|
498
525
|
for line in lines:
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
if m and "remaining_5h_pct" not in result:
|
|
504
|
-
result["remaining_5h_pct"] = int(m[1])
|
|
505
|
-
m = re.search(r"weekly\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})%\s*left", line, re.I)
|
|
506
|
-
if m and "remaining_week_pct" not in result:
|
|
507
|
-
result["remaining_week_pct"] = int(m[1])
|
|
508
|
-
m = re.search(r"weekly\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})(?:%|\b)", line, re.I)
|
|
509
|
-
if m and "remaining_week_pct" not in result:
|
|
510
|
-
result["remaining_week_pct"] = int(m[1])
|
|
526
|
+
for field, pattern in _CODEX_LIMIT_PERCENT_PATTERNS:
|
|
527
|
+
m = pattern.search(line)
|
|
528
|
+
if m and field not in result:
|
|
529
|
+
result[field] = int(m[1])
|
|
511
530
|
|
|
512
531
|
if "remaining_5h_pct" in result and "usage_pct" not in result:
|
|
513
532
|
result["usage_pct"] = max(0, 100 - result["remaining_5h_pct"])
|
|
@@ -640,6 +659,8 @@ _STATUS_VALUE_FIELDS = (
|
|
|
640
659
|
|
|
641
660
|
def _backfill_missing_fields(target, source):
|
|
642
661
|
for field in _STATUS_VALUE_FIELDS:
|
|
662
|
+
if target.get("_skip_5h_backfill") and field in ("remaining_5h_pct", "reset_5h_at"):
|
|
663
|
+
continue
|
|
643
664
|
if target.get(field) is None and source.get(field) is not None:
|
|
644
665
|
target[field] = source[field]
|
|
645
666
|
|
|
@@ -694,6 +715,7 @@ def find_latest_status_artifact(
|
|
|
694
715
|
"reset_week_at": candidate["structured"].get("reset_week_at"),
|
|
695
716
|
"reset_at": candidate["structured"].get("reset_at"),
|
|
696
717
|
"raw_status_text": candidate["structured"].get("raw_status_text"),
|
|
718
|
+
"_skip_5h_backfill": candidate["structured"].get("_skip_5h_backfill"),
|
|
697
719
|
}
|
|
698
720
|
if not parsed:
|
|
699
721
|
continue
|
package/src/status_view.py
CHANGED
|
@@ -75,12 +75,19 @@ def _format_credits(value, empty="n/a"):
|
|
|
75
75
|
|
|
76
76
|
def _format_status_rows(rows, use_color=False, small=False):
|
|
77
77
|
has_provider = len({r["provider"] for r in rows}) > 1 and not small
|
|
78
|
+
has_label = any(r.get("label") for r in rows) and not small
|
|
78
79
|
if small:
|
|
79
80
|
headers = ["SESSION", "STATUS", "OK", "5H", "WEEK", "RESETS", "RESET 5H", "RESET WEEK"]
|
|
80
81
|
elif has_provider:
|
|
81
|
-
headers = ["SESSION", "PROV."
|
|
82
|
+
headers = ["SESSION", "PROV."]
|
|
83
|
+
if has_label:
|
|
84
|
+
headers.append("LABEL")
|
|
85
|
+
headers += ["STATUS", "AUTH", "OK", "5H", "WEEK", "BLOCK", "CR", "RESETS", "RESET 5H", "RESET WEEK", "UPDATED"]
|
|
82
86
|
else:
|
|
83
|
-
headers = ["SESSION"
|
|
87
|
+
headers = ["SESSION"]
|
|
88
|
+
if has_label:
|
|
89
|
+
headers.append("LABEL")
|
|
90
|
+
headers += ["STATUS", "AUTH", "OK", "5H", "WEEK", "BLOCK", "CR", "RESETS", "RESET 5H", "RESET WEEK", "UPDATED"]
|
|
84
91
|
if not rows:
|
|
85
92
|
if small:
|
|
86
93
|
return "SESSION STATUS OK 5H WEEK RESETS RESET 5H RESET WEEK\nNo saved sessions yet."
|
|
@@ -102,6 +109,8 @@ def _format_status_rows(rows, use_color=False, small=False):
|
|
|
102
109
|
base = [_format_session_name(r)]
|
|
103
110
|
if has_provider:
|
|
104
111
|
base.append(r.get("provider") or "n/a")
|
|
112
|
+
if has_label:
|
|
113
|
+
base.append(r.get("label") or "-")
|
|
105
114
|
status = r.get("status") or ("enabled" if r.get("enabled", True) else "disabled")
|
|
106
115
|
base.append(_style(status, "2" if status == "disabled" else "32", use_color))
|
|
107
116
|
if not small:
|