aiuse 3.2.6__tar.gz → 3.2.7__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.
- {aiuse-3.2.6/src/aiuse.egg-info → aiuse-3.2.7}/PKG-INFO +2 -2
- {aiuse-3.2.6 → aiuse-3.2.7}/README.md +1 -1
- {aiuse-3.2.6 → aiuse-3.2.7}/pyproject.toml +1 -1
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/__init__.py +1 -1
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/collectors/cswap.py +1 -1
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/collectors/muse.py +247 -7
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/collectors/openusage_sh.py +1 -1
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/collectors/runner.py +10 -1
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/config.py +12 -3
- {aiuse-3.2.6 → aiuse-3.2.7/src/aiuse.egg-info}/PKG-INFO +2 -2
- {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_config.py +14 -2
- {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_muse.py +83 -1
- {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_runner_consolidation.py +22 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/LICENSE +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/setup.cfg +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/__main__.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/agent_notes.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/ai_stub.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/analysis/__init__.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/analysis/history.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/analysis/local_runtimes.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/analysis/pace.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/analysis/selfdescribe.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/analysis/suggest.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/analysis/use_or_lose.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/attribute.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/chat_format.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/cli.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/collectors/__init__.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/collectors/bailian.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/collectors/base.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/collectors/caut.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/collectors/clinepass.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/collectors/codexbar.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/collectors/grok.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/collectors/hermes.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/collectors/opencode_go.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/collectors/opencode_zen.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/collectors/openrouter.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/collectors/openusage.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/collectors/qwencloud.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/collectors/tokscale.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/credentials.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/ledger.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/macos_trust.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/models.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/report.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/sampler.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/secretspec.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/serve.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/tty.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/tui/__init__.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/tui/app.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/tui/builders.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/watch.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse.egg-info/SOURCES.txt +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse.egg-info/dependency_links.txt +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse.egg-info/entry_points.txt +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse.egg-info/requires.txt +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse.egg-info/top_level.txt +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_attribution.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_bailian.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_base_run_json.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_caut_parse.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_chat_format.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_cli.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_clinepass.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_codexbar_parse.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_credentials.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_cswap_parse.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_grok.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_hermes_parse.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_history.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_install_deps.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_local_runtimes.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_macos_trust.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_models_cli_binary.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_models_pace.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_opencode_go.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_opencode_zen.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_openrouter.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_openusage_parse.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_pace.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_qwencloud.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_release_script.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_report.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_selfdescribe.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_serve.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_shared_quota_semantics.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_snapshots_persistence.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_suggest.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_tokscale_parse.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_tty.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_tui.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_use_or_lose.py +0 -0
- {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_watch.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aiuse
|
|
3
|
-
Version: 3.2.
|
|
3
|
+
Version: 3.2.7
|
|
4
4
|
Summary: Aggregate AI subscription/API usage and flag use-it-or-lose-it allotments
|
|
5
5
|
Author: djbclark
|
|
6
6
|
License-Expression: MIT
|
|
@@ -510,7 +510,7 @@ Lapsed subscriptions: `analysis.lapsed_accounts` maps `"provider/account"` to a
|
|
|
510
510
|
## Notes / limitations
|
|
511
511
|
|
|
512
512
|
- Live quota accuracy depends on each tool's auth (browser cookies, OAuth, keychain). Errors are reported per account rather than aborting the whole run.
|
|
513
|
-
- All enabled collectors (cswap, CodexBar, caut, OpenUsage.ai, OpenUsage.sh, tokscale, qwencloud, bailian) run concurrently; each CodexBar provider is its own subprocess. Default tool timeout is **45s** (`-t` / `config.toml [timeouts]`).
|
|
513
|
+
- All enabled collectors (cswap, CodexBar, caut, OpenUsage.ai, OpenUsage.sh, tokscale, qwencloud, bailian) run concurrently; each CodexBar provider is its own subprocess. Default tool timeout is **45s**; `cswap` and `openusage_sh` are **90s** (`-t` / `config.toml [timeouts]`).
|
|
514
514
|
- Per-window detail still shows $ value, flexibility class, and a **pace** ratio when computable.
|
|
515
515
|
- Duplicate live measurements are retained for cross-checking but only one copy drives recommendations.
|
|
516
516
|
- Dollar values use plan `monthly_price` with waking-hours correction (default 16h/day).
|
|
@@ -469,7 +469,7 @@ Lapsed subscriptions: `analysis.lapsed_accounts` maps `"provider/account"` to a
|
|
|
469
469
|
## Notes / limitations
|
|
470
470
|
|
|
471
471
|
- Live quota accuracy depends on each tool's auth (browser cookies, OAuth, keychain). Errors are reported per account rather than aborting the whole run.
|
|
472
|
-
- All enabled collectors (cswap, CodexBar, caut, OpenUsage.ai, OpenUsage.sh, tokscale, qwencloud, bailian) run concurrently; each CodexBar provider is its own subprocess. Default tool timeout is **45s** (`-t` / `config.toml [timeouts]`).
|
|
472
|
+
- All enabled collectors (cswap, CodexBar, caut, OpenUsage.ai, OpenUsage.sh, tokscale, qwencloud, bailian) run concurrently; each CodexBar provider is its own subprocess. Default tool timeout is **45s**; `cswap` and `openusage_sh` are **90s** (`-t` / `config.toml [timeouts]`).
|
|
473
473
|
- Per-window detail still shows $ value, flexibility class, and a **pace** ratio when computable.
|
|
474
474
|
- Duplicate live measurements are retained for cross-checking but only one copy drives recommendations.
|
|
475
475
|
- Dollar values use plan `monthly_price` with waking-hours correction (default 16h/day).
|
|
@@ -37,7 +37,7 @@ from aiuse.models import coerce_int as _int_or_none
|
|
|
37
37
|
from .base import CollectorError, run_json, which
|
|
38
38
|
|
|
39
39
|
|
|
40
|
-
def collect_cswap(*, timeout: float =
|
|
40
|
+
def collect_cswap(*, timeout: float = 90.0) -> list[AccountUsage]:
|
|
41
41
|
if not which("cswap"):
|
|
42
42
|
raise CollectorError("cswap not found on PATH")
|
|
43
43
|
|
|
@@ -14,9 +14,14 @@ If one transport is absent or fails, the other is tried. Absent both → [] . 40
|
|
|
14
14
|
credential present surfaces as AccountUsage(error=…) only after both transports fail.
|
|
15
15
|
|
|
16
16
|
As of 2026-08, api.meta.ai exposes /models and /status for the LLM| key but no billing
|
|
17
|
-
path (all candidates 404). Cookie path uses
|
|
18
|
-
|
|
17
|
+
path (all candidates 404). Cookie path uses the portal billing banner plus
|
|
18
|
+
month-to-date USAGE_BILLABLE_COST: Muse's UI "balance" is MTD PAYG spend (counts up),
|
|
19
19
|
not prepaid remaining.
|
|
20
|
+
|
|
21
|
+
A monthly Muse Code plan does not publish a monthly used-percent. The plan meter is
|
|
22
|
+
``subs_usage`` on ``POST https://api.meta.ai/muse-code/key`` (Muse CLI keychain access
|
|
23
|
+
token): a rolling window (``window_duration_mins``, 300 for the 5-hour clock) and a
|
|
24
|
+
weekly window. No active subscription omits ``subs_usage``; the row stays spend-only.
|
|
20
25
|
"""
|
|
21
26
|
|
|
22
27
|
from __future__ import annotations
|
|
@@ -26,8 +31,10 @@ import os
|
|
|
26
31
|
import re
|
|
27
32
|
import shutil
|
|
28
33
|
import subprocess
|
|
34
|
+
import threading
|
|
35
|
+
import time
|
|
29
36
|
from collections.abc import Mapping
|
|
30
|
-
from datetime import date
|
|
37
|
+
from datetime import date, datetime, timezone
|
|
31
38
|
from pathlib import Path
|
|
32
39
|
from typing import Any
|
|
33
40
|
from urllib.parse import quote, urlencode
|
|
@@ -83,6 +90,15 @@ _BILLING_FRIENDLY = "LLMDCBillingBannerContainerQuery"
|
|
|
83
90
|
_SPEND_DOC_ID = "28692949813640152"
|
|
84
91
|
_SPEND_FRIENDLY = "LLMDCHomeContentUsageSummaryQuery"
|
|
85
92
|
_PE_AMOUNT_OFFSET = 100.0
|
|
93
|
+
# Muse Code plan meter. The API key cannot read it; the device-login access token can.
|
|
94
|
+
_MUSE_CODE_KEY_URL = "https://api.meta.ai/muse-code/key"
|
|
95
|
+
_KEYCHAIN_SERVICE = "ai.meta.dev.credentials"
|
|
96
|
+
_KEYCHAIN_ACCOUNT = "meta"
|
|
97
|
+
_SUBS_CACHE_TTL_S = 300.0
|
|
98
|
+
_subs_cache_lock = threading.Lock()
|
|
99
|
+
# ``payload`` None means "no cached read". A dict (possibly without subs_usage)
|
|
100
|
+
# is a fresh key response and is reused for five minutes.
|
|
101
|
+
_subs_cache: dict[str, Any] = {"at": 0.0, "payload": None}
|
|
86
102
|
|
|
87
103
|
|
|
88
104
|
def collect_muse(
|
|
@@ -110,7 +126,7 @@ def collect_muse(
|
|
|
110
126
|
if cookie and _is_soft_inventory_row(accounts[0]):
|
|
111
127
|
soft_from_key = accounts
|
|
112
128
|
else:
|
|
113
|
-
return accounts
|
|
129
|
+
return _merge_subscription_windows(accounts, timeout, allow_local=allow_local)
|
|
114
130
|
except CollectorError as exc:
|
|
115
131
|
msg = str(exc)
|
|
116
132
|
errors.append(msg)
|
|
@@ -132,7 +148,11 @@ def collect_muse(
|
|
|
132
148
|
|
|
133
149
|
if cookie:
|
|
134
150
|
try:
|
|
135
|
-
return
|
|
151
|
+
return _merge_subscription_windows(
|
|
152
|
+
_collect_via_cookie(cookie, env, timeout, account=account),
|
|
153
|
+
timeout,
|
|
154
|
+
allow_local=allow_local,
|
|
155
|
+
)
|
|
136
156
|
except CollectorError as exc:
|
|
137
157
|
msg = str(exc)
|
|
138
158
|
errors.append(msg)
|
|
@@ -143,7 +163,7 @@ def collect_muse(
|
|
|
143
163
|
*row.notes,
|
|
144
164
|
f"Cookie balance unavailable: {msg}",
|
|
145
165
|
]
|
|
146
|
-
return soft_from_key
|
|
166
|
+
return _merge_subscription_windows(soft_from_key, timeout, allow_local=allow_local)
|
|
147
167
|
if key:
|
|
148
168
|
# Both failed
|
|
149
169
|
raise CollectorError("; ".join(errors)) from exc
|
|
@@ -162,7 +182,7 @@ def collect_muse(
|
|
|
162
182
|
raise
|
|
163
183
|
|
|
164
184
|
if soft_from_key is not None:
|
|
165
|
-
return soft_from_key
|
|
185
|
+
return _merge_subscription_windows(soft_from_key, timeout, allow_local=allow_local)
|
|
166
186
|
|
|
167
187
|
# One transport was tried and failed with non-401 without fallback
|
|
168
188
|
if errors:
|
|
@@ -1244,6 +1264,226 @@ def _windows_from_payload(data: dict) -> list[QuotaWindow]:
|
|
|
1244
1264
|
return windows
|
|
1245
1265
|
|
|
1246
1266
|
|
|
1267
|
+
def _epoch_to_utc(value: Any) -> datetime | None:
|
|
1268
|
+
"""Muse sends ``resets_at`` as unix seconds (or an ISO string)."""
|
|
1269
|
+
if isinstance(value, bool) or value is None:
|
|
1270
|
+
return None
|
|
1271
|
+
if isinstance(value, (int, float)):
|
|
1272
|
+
seconds = float(value)
|
|
1273
|
+
if seconds > 10_000_000_000:
|
|
1274
|
+
seconds /= 1000.0
|
|
1275
|
+
if seconds <= 0:
|
|
1276
|
+
return None
|
|
1277
|
+
return datetime.fromtimestamp(seconds, timezone.utc)
|
|
1278
|
+
if isinstance(value, str):
|
|
1279
|
+
return parse_dt(value)
|
|
1280
|
+
return None
|
|
1281
|
+
|
|
1282
|
+
|
|
1283
|
+
def _windows_from_subs_usage(payload: Any) -> list[QuotaWindow]:
|
|
1284
|
+
"""Map ``subs_usage`` from ``POST /muse-code/key`` into clock windows.
|
|
1285
|
+
|
|
1286
|
+
``used_percent`` is the share consumed. The rolling window's
|
|
1287
|
+
``window_duration_mins`` picks the clock (300 is the 5-hour plan window).
|
|
1288
|
+
The weekly object has no duration of its own.
|
|
1289
|
+
"""
|
|
1290
|
+
if not isinstance(payload, dict):
|
|
1291
|
+
return []
|
|
1292
|
+
usage = payload.get("subs_usage")
|
|
1293
|
+
if not isinstance(usage, dict):
|
|
1294
|
+
return []
|
|
1295
|
+
windows: list[QuotaWindow] = []
|
|
1296
|
+
rolling = usage.get("window")
|
|
1297
|
+
if isinstance(rolling, dict):
|
|
1298
|
+
window = _quota_window_from_subs_spec(
|
|
1299
|
+
rolling,
|
|
1300
|
+
label="Muse 5-hour",
|
|
1301
|
+
minutes=300,
|
|
1302
|
+
)
|
|
1303
|
+
if window is not None:
|
|
1304
|
+
windows.append(window)
|
|
1305
|
+
weekly = usage.get("weekly")
|
|
1306
|
+
if isinstance(weekly, dict):
|
|
1307
|
+
window = _quota_window_from_subs_spec(
|
|
1308
|
+
weekly,
|
|
1309
|
+
label="Muse weekly",
|
|
1310
|
+
minutes=10080,
|
|
1311
|
+
)
|
|
1312
|
+
if window is not None:
|
|
1313
|
+
windows.append(window)
|
|
1314
|
+
return windows
|
|
1315
|
+
|
|
1316
|
+
|
|
1317
|
+
def _quota_window_from_subs_spec(
|
|
1318
|
+
spec: dict[str, Any],
|
|
1319
|
+
*,
|
|
1320
|
+
label: str,
|
|
1321
|
+
minutes: int,
|
|
1322
|
+
) -> QuotaWindow | None:
|
|
1323
|
+
percent = spec.get("used_percent")
|
|
1324
|
+
if isinstance(percent, bool) or not isinstance(percent, (int, float)):
|
|
1325
|
+
return None
|
|
1326
|
+
used = float(percent)
|
|
1327
|
+
duration = spec.get("window_duration_mins")
|
|
1328
|
+
if isinstance(duration, (int, float)) and not isinstance(duration, bool) and duration > 0:
|
|
1329
|
+
minutes = int(duration)
|
|
1330
|
+
if minutes == 300:
|
|
1331
|
+
label = "Muse 5-hour"
|
|
1332
|
+
elif minutes == 10080:
|
|
1333
|
+
label = "Muse weekly"
|
|
1334
|
+
else:
|
|
1335
|
+
label = f"Muse {minutes}m"
|
|
1336
|
+
resets = _epoch_to_utc(spec.get("resets_at"))
|
|
1337
|
+
raw = {
|
|
1338
|
+
"used_percent": used,
|
|
1339
|
+
"resets_at": spec.get("resets_at"),
|
|
1340
|
+
"window_duration_mins": spec.get("window_duration_mins"),
|
|
1341
|
+
}
|
|
1342
|
+
return QuotaWindow(
|
|
1343
|
+
label=label,
|
|
1344
|
+
used_percent=used,
|
|
1345
|
+
remaining_percent=max(0.0, 100.0 - used),
|
|
1346
|
+
resets_at=resets,
|
|
1347
|
+
window_minutes=minutes,
|
|
1348
|
+
raw={key: value for key, value in raw.items() if value is not None},
|
|
1349
|
+
)
|
|
1350
|
+
|
|
1351
|
+
|
|
1352
|
+
def _plan_note_from_key_payload(payload: Any, windows: list[QuotaWindow]) -> str | None:
|
|
1353
|
+
if not isinstance(payload, dict):
|
|
1354
|
+
return None
|
|
1355
|
+
tier = payload.get("subs_tier_name")
|
|
1356
|
+
if isinstance(tier, str) and tier.strip():
|
|
1357
|
+
return f"Muse Code plan: {tier.strip()}."
|
|
1358
|
+
if payload.get("is_subs_active") is False and not windows:
|
|
1359
|
+
return (
|
|
1360
|
+
"Muse Code subscription is inactive, so Meta reports no plan usage percent. "
|
|
1361
|
+
"The row is pay-as-you-go spend only."
|
|
1362
|
+
)
|
|
1363
|
+
return None
|
|
1364
|
+
|
|
1365
|
+
|
|
1366
|
+
def _read_muse_keychain_access_token() -> str | None:
|
|
1367
|
+
"""OAuth access token from the Muse CLI login. Never the API key."""
|
|
1368
|
+
try:
|
|
1369
|
+
result = subprocess.run(
|
|
1370
|
+
[
|
|
1371
|
+
"security",
|
|
1372
|
+
"find-generic-password",
|
|
1373
|
+
"-s",
|
|
1374
|
+
_KEYCHAIN_SERVICE,
|
|
1375
|
+
"-a",
|
|
1376
|
+
_KEYCHAIN_ACCOUNT,
|
|
1377
|
+
"-w",
|
|
1378
|
+
],
|
|
1379
|
+
capture_output=True,
|
|
1380
|
+
text=True,
|
|
1381
|
+
timeout=5,
|
|
1382
|
+
check=False,
|
|
1383
|
+
)
|
|
1384
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
1385
|
+
return None
|
|
1386
|
+
if result.returncode != 0:
|
|
1387
|
+
return None
|
|
1388
|
+
try:
|
|
1389
|
+
payload = json.loads(result.stdout.strip())
|
|
1390
|
+
except json.JSONDecodeError:
|
|
1391
|
+
return None
|
|
1392
|
+
if not isinstance(payload, dict):
|
|
1393
|
+
return None
|
|
1394
|
+
token = payload.get("access_token")
|
|
1395
|
+
if not isinstance(token, str) or not token.strip():
|
|
1396
|
+
return None
|
|
1397
|
+
return token.strip()
|
|
1398
|
+
|
|
1399
|
+
|
|
1400
|
+
def _fetch_muse_code_key(token: str, timeout: float) -> dict[str, Any] | None:
|
|
1401
|
+
try:
|
|
1402
|
+
response = requests.post(
|
|
1403
|
+
_MUSE_CODE_KEY_URL,
|
|
1404
|
+
json={},
|
|
1405
|
+
timeout=timeout,
|
|
1406
|
+
headers={
|
|
1407
|
+
"Authorization": f"Bearer {token}",
|
|
1408
|
+
"Accept": "application/json",
|
|
1409
|
+
"Content-Type": "application/json",
|
|
1410
|
+
"x-api-version": "1.0.0",
|
|
1411
|
+
"User-Agent": _USER_AGENT,
|
|
1412
|
+
},
|
|
1413
|
+
)
|
|
1414
|
+
except requests.RequestException:
|
|
1415
|
+
return None
|
|
1416
|
+
if response.status_code != 200:
|
|
1417
|
+
return None
|
|
1418
|
+
try:
|
|
1419
|
+
payload = response.json()
|
|
1420
|
+
except ValueError:
|
|
1421
|
+
return None
|
|
1422
|
+
if not isinstance(payload, dict):
|
|
1423
|
+
return None
|
|
1424
|
+
# The response repeats the API key. Do not keep it on the account.
|
|
1425
|
+
payload.pop("api_key", None)
|
|
1426
|
+
payload.pop("user_email", None)
|
|
1427
|
+
payload.pop("user_avatar_url", None)
|
|
1428
|
+
return payload
|
|
1429
|
+
|
|
1430
|
+
|
|
1431
|
+
def _subscription_windows_for_local_login(timeout: float) -> tuple[list[QuotaWindow], str | None]:
|
|
1432
|
+
"""Plan windows from the local Muse CLI login.
|
|
1433
|
+
|
|
1434
|
+
The key endpoint is rate-limited, and ``aiuse watch`` collects on a short
|
|
1435
|
+
interval, so a hit is reused for five minutes. Unit tests must not read
|
|
1436
|
+
the operator keychain or call Meta.
|
|
1437
|
+
"""
|
|
1438
|
+
if os.environ.get("PYTEST_CURRENT_TEST"):
|
|
1439
|
+
return [], None
|
|
1440
|
+
now = time.monotonic()
|
|
1441
|
+
with _subs_cache_lock:
|
|
1442
|
+
cached_at = float(_subs_cache.get("at") or 0.0)
|
|
1443
|
+
cached = _subs_cache.get("payload")
|
|
1444
|
+
if isinstance(cached, dict) and now - cached_at < _SUBS_CACHE_TTL_S:
|
|
1445
|
+
payload = cached
|
|
1446
|
+
else:
|
|
1447
|
+
payload = None
|
|
1448
|
+
if payload is None:
|
|
1449
|
+
token = _read_muse_keychain_access_token()
|
|
1450
|
+
payload = _fetch_muse_code_key(token, timeout) if token else None
|
|
1451
|
+
# Cache a miss too, so a locked keychain or an inactive plan does not
|
|
1452
|
+
# hit the rate-limited key endpoint on every watch tick.
|
|
1453
|
+
stored = payload if isinstance(payload, dict) else {}
|
|
1454
|
+
with _subs_cache_lock:
|
|
1455
|
+
_subs_cache["at"] = time.monotonic()
|
|
1456
|
+
_subs_cache["payload"] = stored
|
|
1457
|
+
payload = stored
|
|
1458
|
+
windows = _windows_from_subs_usage(payload)
|
|
1459
|
+
return windows, _plan_note_from_key_payload(payload, windows)
|
|
1460
|
+
|
|
1461
|
+
|
|
1462
|
+
def _merge_subscription_windows(
|
|
1463
|
+
accounts: list[AccountUsage],
|
|
1464
|
+
timeout: float,
|
|
1465
|
+
*,
|
|
1466
|
+
allow_local: bool,
|
|
1467
|
+
) -> list[AccountUsage]:
|
|
1468
|
+
"""Attach Muse Code plan windows when the local login reports them."""
|
|
1469
|
+
if not accounts or not allow_local:
|
|
1470
|
+
return accounts
|
|
1471
|
+
windows, note = _subscription_windows_for_local_login(timeout)
|
|
1472
|
+
if not windows and not note:
|
|
1473
|
+
return accounts
|
|
1474
|
+
for row in accounts:
|
|
1475
|
+
if row.error:
|
|
1476
|
+
continue
|
|
1477
|
+
if windows:
|
|
1478
|
+
present = {window.label for window in row.windows}
|
|
1479
|
+
row.windows.extend(window for window in windows if window.label not in present)
|
|
1480
|
+
if row.billing_kind == BillingKind.PAYG_API:
|
|
1481
|
+
row.billing_kind = BillingKind.SUBSCRIPTION_WINDOW
|
|
1482
|
+
if note and note not in row.notes:
|
|
1483
|
+
row.notes.append(note)
|
|
1484
|
+
return accounts
|
|
1485
|
+
|
|
1486
|
+
|
|
1247
1487
|
def _resolve_key_and_account(
|
|
1248
1488
|
env: Mapping[str, str],
|
|
1249
1489
|
timeout: float,
|
|
@@ -29,7 +29,7 @@ def _window_label(provider_id: str, key: str, metric: dict[object, object]) -> s
|
|
|
29
29
|
return "weekly" if label.casefold() == "7d" else label
|
|
30
30
|
|
|
31
31
|
|
|
32
|
-
def collect_openusage_sh(*, timeout: float =
|
|
32
|
+
def collect_openusage_sh(*, timeout: float = 90.0) -> list[AccountUsage]:
|
|
33
33
|
"""Read OpenUsage.sh's documented versioned JSON export.
|
|
34
34
|
|
|
35
35
|
Only explicit subscription/rate-limit percentage metrics become quota
|
|
@@ -335,7 +335,16 @@ def _select_and_cross_check(
|
|
|
335
335
|
selected.extend(primary_rows)
|
|
336
336
|
else:
|
|
337
337
|
live = [a for a in primary_rows if _has_live_data(a)]
|
|
338
|
-
|
|
338
|
+
# A Max-plan OpenUsage.ai row with zero windows is an identity
|
|
339
|
+
# label, not a Claude reading. Keeping it is what showed Claude
|
|
340
|
+
# with no usage when CodexBar and cswap returned nothing.
|
|
341
|
+
blank_openusage_claude = (
|
|
342
|
+
provider == "claude" and primary == "openusage_ai" and not any(row.error for row in primary_rows)
|
|
343
|
+
)
|
|
344
|
+
if live:
|
|
345
|
+
selected.extend(live)
|
|
346
|
+
elif not blank_openusage_claude:
|
|
347
|
+
selected.extend(primary_rows)
|
|
339
348
|
else:
|
|
340
349
|
# No priority source present — keep whatever we have.
|
|
341
350
|
selected.extend(rows)
|
|
@@ -17,6 +17,13 @@ from typing import Any
|
|
|
17
17
|
# Default wall-clock budget for every external CLI subprocess. Tools either
|
|
18
18
|
# return within tens of seconds or hang; long budgets only delay failure.
|
|
19
19
|
DEFAULT_SUBPROCESS_TIMEOUT = 45.0
|
|
20
|
+
# `cswap list --json` is one subprocess. Beside the other collectors it crossed
|
|
21
|
+
# 45s (snapshot 2026-10-04T12:55:23Z), and Claude then had no canonical row.
|
|
22
|
+
# Same 90s budget as OpenUsage.sh: long enough for that slow success.
|
|
23
|
+
CSWAP_TIMEOUT = 90.0
|
|
24
|
+
# `openusage-sh export` polls every configured provider. A quiet run is about
|
|
25
|
+
# 30s, and the same export often crosses 45s while the other collectors run.
|
|
26
|
+
OPENUSAGE_SH_TIMEOUT = 90.0
|
|
20
27
|
|
|
21
28
|
DEFAULT_CONFIG: dict[str, Any] = {
|
|
22
29
|
# Subprocess timeouts (seconds). ``default`` applies to any tool that does
|
|
@@ -24,6 +31,8 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
|
|
24
31
|
# tokscale. Override via config.toml or CLI ``--timeout`` / ``-t``.
|
|
25
32
|
"timeouts": {
|
|
26
33
|
"default": DEFAULT_SUBPROCESS_TIMEOUT,
|
|
34
|
+
"cswap": CSWAP_TIMEOUT,
|
|
35
|
+
"openusage_sh": OPENUSAGE_SH_TIMEOUT,
|
|
27
36
|
},
|
|
28
37
|
"analysis": {
|
|
29
38
|
"min_remaining_percent": 40,
|
|
@@ -684,13 +693,13 @@ def _default_toml_text() -> str:
|
|
|
684
693
|
"# Tools either return quickly or hang — long budgets only delay failure.\n"
|
|
685
694
|
f"default = {DEFAULT_SUBPROCESS_TIMEOUT:g}\n"
|
|
686
695
|
"\n"
|
|
687
|
-
"# Optional per-tool overrides (omit to use
|
|
688
|
-
"# cswap =
|
|
696
|
+
"# Optional per-tool overrides (omit to use the built-in for that tool):\n"
|
|
697
|
+
"# cswap = 90\n"
|
|
689
698
|
"# codexbar = 45\n"
|
|
690
699
|
"# codexbar_discovery = 45 # `codexbar config providers` (local, usually ms)\n"
|
|
691
700
|
"# caut = 45\n"
|
|
692
701
|
"# openusage_ai = 45\n"
|
|
693
|
-
"# openusage_sh =
|
|
702
|
+
"# openusage_sh = 90\n"
|
|
694
703
|
"# tokscale = 45\n"
|
|
695
704
|
"# hermes = 45\n"
|
|
696
705
|
"\n"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aiuse
|
|
3
|
-
Version: 3.2.
|
|
3
|
+
Version: 3.2.7
|
|
4
4
|
Summary: Aggregate AI subscription/API usage and flag use-it-or-lose-it allotments
|
|
5
5
|
Author: djbclark
|
|
6
6
|
License-Expression: MIT
|
|
@@ -510,7 +510,7 @@ Lapsed subscriptions: `analysis.lapsed_accounts` maps `"provider/account"` to a
|
|
|
510
510
|
## Notes / limitations
|
|
511
511
|
|
|
512
512
|
- Live quota accuracy depends on each tool's auth (browser cookies, OAuth, keychain). Errors are reported per account rather than aborting the whole run.
|
|
513
|
-
- All enabled collectors (cswap, CodexBar, caut, OpenUsage.ai, OpenUsage.sh, tokscale, qwencloud, bailian) run concurrently; each CodexBar provider is its own subprocess. Default tool timeout is **45s** (`-t` / `config.toml [timeouts]`).
|
|
513
|
+
- All enabled collectors (cswap, CodexBar, caut, OpenUsage.ai, OpenUsage.sh, tokscale, qwencloud, bailian) run concurrently; each CodexBar provider is its own subprocess. Default tool timeout is **45s**; `cswap` and `openusage_sh` are **90s** (`-t` / `config.toml [timeouts]`).
|
|
514
514
|
- Per-window detail still shows $ value, flexibility class, and a **pace** ratio when computable.
|
|
515
515
|
- Duplicate live measurements are retained for cross-checking but only one copy drives recommendations.
|
|
516
516
|
- Dollar values use plan `monthly_price` with waking-hours correction (default 16h/day).
|
|
@@ -3,8 +3,10 @@ from pathlib import Path
|
|
|
3
3
|
import pytest
|
|
4
4
|
|
|
5
5
|
from aiuse.config import (
|
|
6
|
+
CSWAP_TIMEOUT,
|
|
6
7
|
DEFAULT_CONFIG,
|
|
7
8
|
DEFAULT_SUBPROCESS_TIMEOUT,
|
|
9
|
+
OPENUSAGE_SH_TIMEOUT,
|
|
8
10
|
collector_health_url,
|
|
9
11
|
default_config_dir,
|
|
10
12
|
default_config_path,
|
|
@@ -112,7 +114,9 @@ def test_explicit_config_remains_usable_when_both_default_files_exist(monkeypatc
|
|
|
112
114
|
(config_dir / "services.yaml").write_text("analysis:\n min_remaining_percent: 55\n", encoding="utf-8")
|
|
113
115
|
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
|
114
116
|
|
|
115
|
-
|
|
117
|
+
loaded = load_config(canonical)
|
|
118
|
+
assert timeout_for(loaded, "tokscale") == 30
|
|
119
|
+
assert timeout_for(loaded, "cswap") == 90
|
|
116
120
|
|
|
117
121
|
|
|
118
122
|
def test_load_config_explicit_missing_path_exits():
|
|
@@ -132,6 +136,10 @@ def test_default_timeouts_are_45s():
|
|
|
132
136
|
assert DEFAULT_SUBPROCESS_TIMEOUT == 45.0
|
|
133
137
|
assert timeout_for({}, "tokscale") == 45.0
|
|
134
138
|
assert timeout_for({"timeouts": {"default": 45}}, "cswap") == 45.0
|
|
139
|
+
assert OPENUSAGE_SH_TIMEOUT == 90.0
|
|
140
|
+
assert CSWAP_TIMEOUT == 90.0
|
|
141
|
+
assert timeout_for(DEFAULT_CONFIG, "openusage_sh") == 90.0
|
|
142
|
+
assert timeout_for(DEFAULT_CONFIG, "cswap") == 90.0
|
|
135
143
|
|
|
136
144
|
|
|
137
145
|
def test_timeout_for_per_tool_and_force_precedence():
|
|
@@ -140,6 +148,8 @@ def test_timeout_for_per_tool_and_force_precedence():
|
|
|
140
148
|
cfg_no_force = {"timeouts": {"default": 45, "tokscale": 20}}
|
|
141
149
|
assert timeout_for(cfg_no_force, "tokscale") == 20.0
|
|
142
150
|
assert timeout_for(cfg_no_force, "cswap") == 45.0
|
|
151
|
+
assert timeout_for({"timeouts": {"openusage_sh": 20}}, "openusage_sh") == 20.0
|
|
152
|
+
assert timeout_for({"timeouts": {"cswap": 30}}, "cswap") == 30.0
|
|
143
153
|
|
|
144
154
|
|
|
145
155
|
def test_load_config_merges_toml_timeouts(monkeypatch, tmp_path):
|
|
@@ -153,8 +163,10 @@ def test_load_config_merges_toml_timeouts(monkeypatch, tmp_path):
|
|
|
153
163
|
|
|
154
164
|
config = load_config()
|
|
155
165
|
|
|
156
|
-
assert timeout_for(config, "cswap") == 30.0
|
|
157
166
|
assert timeout_for(config, "tokscale") == 12.0
|
|
167
|
+
# A generic default does not erase the built-in cswap or OpenUsage.sh budget.
|
|
168
|
+
assert timeout_for(config, "cswap") == 90.0
|
|
169
|
+
assert timeout_for(config, "openusage_sh") == 90.0
|
|
158
170
|
|
|
159
171
|
|
|
160
172
|
def test_ensure_config_dir_creates_nested_levels(monkeypatch, tmp_path):
|
|
@@ -6,7 +6,7 @@ import requests
|
|
|
6
6
|
|
|
7
7
|
from aiuse.collectors.base import CollectorError
|
|
8
8
|
from aiuse.collectors.muse import collect_muse
|
|
9
|
-
from aiuse.models import BillingKind
|
|
9
|
+
from aiuse.models import AccountUsage, BillingKind, UsageCredits
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
def test_collect_muse_is_quiet_until_key_is_explicitly_supplied():
|
|
@@ -605,3 +605,85 @@ def test_collect_via_cookie_rejected_session_does_not_scrape_html(monkeypatch):
|
|
|
605
605
|
with pytest.raises(CollectorError, match="api/auth/login"):
|
|
606
606
|
muse_mod._collect_via_cookie("llm_sess=stale", {}, 5.0)
|
|
607
607
|
assert seen == ["https://dev.meta.ai/api/auth/me"]
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def test_windows_from_subs_usage_maps_five_hour_and_weekly():
|
|
611
|
+
from aiuse.collectors import muse as muse_mod
|
|
612
|
+
|
|
613
|
+
windows = muse_mod._windows_from_subs_usage(
|
|
614
|
+
{
|
|
615
|
+
"is_subs_active": True,
|
|
616
|
+
"subs_tier_name": "Everyday Usage",
|
|
617
|
+
"subs_usage": {
|
|
618
|
+
"window": {
|
|
619
|
+
"used_percent": 12,
|
|
620
|
+
"resets_at": 1788431188,
|
|
621
|
+
"window_duration_mins": 300,
|
|
622
|
+
},
|
|
623
|
+
"weekly": {"used_percent": 4, "resets_at": 1788739200},
|
|
624
|
+
},
|
|
625
|
+
}
|
|
626
|
+
)
|
|
627
|
+
by_label = {window.label: window for window in windows}
|
|
628
|
+
assert set(by_label) == {"Muse 5-hour", "Muse weekly"}
|
|
629
|
+
assert by_label["Muse 5-hour"].used_percent == 12
|
|
630
|
+
assert by_label["Muse 5-hour"].remaining_percent == 88
|
|
631
|
+
assert by_label["Muse 5-hour"].window_minutes == 300
|
|
632
|
+
assert by_label["Muse 5-hour"].resets_at is not None
|
|
633
|
+
assert by_label["Muse weekly"].used_percent == 4
|
|
634
|
+
assert by_label["Muse weekly"].window_minutes == 10080
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def test_windows_from_subs_usage_keeps_zero_and_drops_inactive_payload():
|
|
638
|
+
from aiuse.collectors import muse as muse_mod
|
|
639
|
+
|
|
640
|
+
untouched = muse_mod._windows_from_subs_usage(
|
|
641
|
+
{"subs_usage": {"window": {"used_percent": 0, "window_duration_mins": 300}}}
|
|
642
|
+
)
|
|
643
|
+
assert untouched[0].used_percent == 0
|
|
644
|
+
assert untouched[0].remaining_percent == 100
|
|
645
|
+
assert muse_mod._windows_from_subs_usage({"is_subs_active": False, "subs_usage": None}) == []
|
|
646
|
+
note = muse_mod._plan_note_from_key_payload({"is_subs_active": False}, [])
|
|
647
|
+
assert note is not None
|
|
648
|
+
assert "no plan usage percent" in note
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def test_merge_subscription_windows_promotes_spend_row(monkeypatch):
|
|
652
|
+
from aiuse.collectors import muse as muse_mod
|
|
653
|
+
|
|
654
|
+
windows = muse_mod._windows_from_subs_usage(
|
|
655
|
+
{
|
|
656
|
+
"subs_usage": {
|
|
657
|
+
"window": {"used_percent": 20, "window_duration_mins": 300},
|
|
658
|
+
"weekly": {"used_percent": 7},
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
)
|
|
662
|
+
monkeypatch.setattr(
|
|
663
|
+
muse_mod,
|
|
664
|
+
"_subscription_windows_for_local_login",
|
|
665
|
+
lambda _timeout: (windows, "Muse Code plan: Everyday Usage."),
|
|
666
|
+
)
|
|
667
|
+
row = AccountUsage(
|
|
668
|
+
source="muse",
|
|
669
|
+
provider="muse",
|
|
670
|
+
billing_kind=BillingKind.PAYG_API,
|
|
671
|
+
usage_credits=UsageCredits(used=0.30, currency="USD"),
|
|
672
|
+
notes=["spent already"],
|
|
673
|
+
)
|
|
674
|
+
merged = muse_mod._merge_subscription_windows([row], 5.0, allow_local=True)
|
|
675
|
+
assert merged[0].billing_kind == BillingKind.SUBSCRIPTION_WINDOW
|
|
676
|
+
assert merged[0].usage_credits is not None
|
|
677
|
+
assert merged[0].usage_credits.used == 0.30
|
|
678
|
+
assert [window.label for window in merged[0].windows] == ["Muse 5-hour", "Muse weekly"]
|
|
679
|
+
assert "Muse Code plan: Everyday Usage." in merged[0].notes
|
|
680
|
+
|
|
681
|
+
untouched = AccountUsage(
|
|
682
|
+
source="muse",
|
|
683
|
+
provider="muse",
|
|
684
|
+
billing_kind=BillingKind.PAYG_API,
|
|
685
|
+
usage_credits=UsageCredits(used=1.0, currency="USD"),
|
|
686
|
+
)
|
|
687
|
+
skipped = muse_mod._merge_subscription_windows([untouched], 5.0, allow_local=False)
|
|
688
|
+
assert skipped[0].billing_kind == BillingKind.PAYG_API
|
|
689
|
+
assert skipped[0].windows == []
|
|
@@ -379,6 +379,28 @@ def test_claude_falls_back_to_tokscale_when_cswap_and_codexbar_empty():
|
|
|
379
379
|
assert any("falling back" in check.message for check in checks)
|
|
380
380
|
|
|
381
381
|
|
|
382
|
+
def test_openusage_ai_claude_without_windows_is_not_a_reading():
|
|
383
|
+
row = _account("openusage_ai", "claude")
|
|
384
|
+
row.windows = []
|
|
385
|
+
row.plan = "Max 5x"
|
|
386
|
+
|
|
387
|
+
selected, checks = _select_and_cross_check([row], cswap_authoritative=True)
|
|
388
|
+
|
|
389
|
+
assert selected == []
|
|
390
|
+
assert any("cswap returned no" in check.message for check in checks)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def test_openusage_ai_claude_without_windows_does_not_hide_a_live_peer():
|
|
394
|
+
empty = _account("openusage_ai", "claude")
|
|
395
|
+
empty.windows = []
|
|
396
|
+
empty.plan = "Max 5x"
|
|
397
|
+
tokscale = _account("tokscale", "claude")
|
|
398
|
+
|
|
399
|
+
selected, _checks = _select_and_cross_check([empty, tokscale], cswap_authoritative=True)
|
|
400
|
+
|
|
401
|
+
assert [account.source for account in selected] == ["tokscale"]
|
|
402
|
+
|
|
403
|
+
|
|
382
404
|
def test_claude_keeps_cswap_error_when_no_alternate_live_source():
|
|
383
405
|
cswap_err = _account("cswap", "claude", error="no data")
|
|
384
406
|
selected, checks = _select_and_cross_check([cswap_err], cswap_authoritative=True)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|