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.
Files changed (96) hide show
  1. {aiuse-3.2.6/src/aiuse.egg-info → aiuse-3.2.7}/PKG-INFO +2 -2
  2. {aiuse-3.2.6 → aiuse-3.2.7}/README.md +1 -1
  3. {aiuse-3.2.6 → aiuse-3.2.7}/pyproject.toml +1 -1
  4. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/__init__.py +1 -1
  5. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/collectors/cswap.py +1 -1
  6. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/collectors/muse.py +247 -7
  7. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/collectors/openusage_sh.py +1 -1
  8. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/collectors/runner.py +10 -1
  9. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/config.py +12 -3
  10. {aiuse-3.2.6 → aiuse-3.2.7/src/aiuse.egg-info}/PKG-INFO +2 -2
  11. {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_config.py +14 -2
  12. {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_muse.py +83 -1
  13. {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_runner_consolidation.py +22 -0
  14. {aiuse-3.2.6 → aiuse-3.2.7}/LICENSE +0 -0
  15. {aiuse-3.2.6 → aiuse-3.2.7}/setup.cfg +0 -0
  16. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/__main__.py +0 -0
  17. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/agent_notes.py +0 -0
  18. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/ai_stub.py +0 -0
  19. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/analysis/__init__.py +0 -0
  20. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/analysis/history.py +0 -0
  21. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/analysis/local_runtimes.py +0 -0
  22. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/analysis/pace.py +0 -0
  23. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/analysis/selfdescribe.py +0 -0
  24. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/analysis/suggest.py +0 -0
  25. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/analysis/use_or_lose.py +0 -0
  26. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/attribute.py +0 -0
  27. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/chat_format.py +0 -0
  28. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/cli.py +0 -0
  29. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/collectors/__init__.py +0 -0
  30. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/collectors/bailian.py +0 -0
  31. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/collectors/base.py +0 -0
  32. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/collectors/caut.py +0 -0
  33. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/collectors/clinepass.py +0 -0
  34. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/collectors/codexbar.py +0 -0
  35. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/collectors/grok.py +0 -0
  36. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/collectors/hermes.py +0 -0
  37. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/collectors/opencode_go.py +0 -0
  38. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/collectors/opencode_zen.py +0 -0
  39. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/collectors/openrouter.py +0 -0
  40. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/collectors/openusage.py +0 -0
  41. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/collectors/qwencloud.py +0 -0
  42. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/collectors/tokscale.py +0 -0
  43. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/credentials.py +0 -0
  44. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/ledger.py +0 -0
  45. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/macos_trust.py +0 -0
  46. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/models.py +0 -0
  47. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/report.py +0 -0
  48. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/sampler.py +0 -0
  49. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/secretspec.py +0 -0
  50. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/serve.py +0 -0
  51. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/tty.py +0 -0
  52. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/tui/__init__.py +0 -0
  53. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/tui/app.py +0 -0
  54. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/tui/builders.py +0 -0
  55. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse/watch.py +0 -0
  56. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse.egg-info/SOURCES.txt +0 -0
  57. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse.egg-info/dependency_links.txt +0 -0
  58. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse.egg-info/entry_points.txt +0 -0
  59. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse.egg-info/requires.txt +0 -0
  60. {aiuse-3.2.6 → aiuse-3.2.7}/src/aiuse.egg-info/top_level.txt +0 -0
  61. {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_attribution.py +0 -0
  62. {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_bailian.py +0 -0
  63. {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_base_run_json.py +0 -0
  64. {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_caut_parse.py +0 -0
  65. {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_chat_format.py +0 -0
  66. {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_cli.py +0 -0
  67. {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_clinepass.py +0 -0
  68. {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_codexbar_parse.py +0 -0
  69. {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_credentials.py +0 -0
  70. {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_cswap_parse.py +0 -0
  71. {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_grok.py +0 -0
  72. {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_hermes_parse.py +0 -0
  73. {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_history.py +0 -0
  74. {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_install_deps.py +0 -0
  75. {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_local_runtimes.py +0 -0
  76. {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_macos_trust.py +0 -0
  77. {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_models_cli_binary.py +0 -0
  78. {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_models_pace.py +0 -0
  79. {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_opencode_go.py +0 -0
  80. {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_opencode_zen.py +0 -0
  81. {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_openrouter.py +0 -0
  82. {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_openusage_parse.py +0 -0
  83. {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_pace.py +0 -0
  84. {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_qwencloud.py +0 -0
  85. {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_release_script.py +0 -0
  86. {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_report.py +0 -0
  87. {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_selfdescribe.py +0 -0
  88. {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_serve.py +0 -0
  89. {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_shared_quota_semantics.py +0 -0
  90. {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_snapshots_persistence.py +0 -0
  91. {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_suggest.py +0 -0
  92. {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_tokscale_parse.py +0 -0
  93. {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_tty.py +0 -0
  94. {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_tui.py +0 -0
  95. {aiuse-3.2.6 → aiuse-3.2.7}/tests/test_use_or_lose.py +0 -0
  96. {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.6
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).
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aiuse"
7
- version = "3.2.6"
7
+ version = "3.2.7"
8
8
  description = "Aggregate AI subscription/API usage and flag use-it-or-lose-it allotments"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,3 +1,3 @@
1
1
  """Aggregate provider quotas and flag use-it-or-lose-it allotments."""
2
2
 
3
- __version__ = "3.2.6"
3
+ __version__ = "3.2.7"
@@ -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 = 45.0) -> list[AccountUsage]:
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 LLMDCBillingBannerContainerQuery plus
18
- LLMDCHomeContentUsageSummaryQuery: Muse's UI "balance" is MTD PAYG spend (counts up),
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 _collect_via_cookie(cookie, env, timeout, account=account)
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 = 45.0) -> list[AccountUsage]:
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
- selected.extend(live if live else primary_rows)
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 default):\n"
688
- "# cswap = 45\n"
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 = 45\n"
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.6
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
- assert timeout_for(load_config(canonical), "cswap") == 30
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