aiuse 3.2.4__tar.gz → 3.2.6__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.4/src/aiuse.egg-info → aiuse-3.2.6}/PKG-INFO +1 -1
  2. {aiuse-3.2.4 → aiuse-3.2.6}/pyproject.toml +1 -1
  3. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/__init__.py +1 -1
  4. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/collectors/muse.py +331 -20
  5. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/credentials.py +15 -6
  6. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/report.py +20 -22
  7. {aiuse-3.2.4 → aiuse-3.2.6/src/aiuse.egg-info}/PKG-INFO +1 -1
  8. {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_credentials.py +20 -1
  9. {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_muse.py +118 -0
  10. {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_report.py +82 -0
  11. {aiuse-3.2.4 → aiuse-3.2.6}/LICENSE +0 -0
  12. {aiuse-3.2.4 → aiuse-3.2.6}/README.md +0 -0
  13. {aiuse-3.2.4 → aiuse-3.2.6}/setup.cfg +0 -0
  14. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/__main__.py +0 -0
  15. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/agent_notes.py +0 -0
  16. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/ai_stub.py +0 -0
  17. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/analysis/__init__.py +0 -0
  18. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/analysis/history.py +0 -0
  19. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/analysis/local_runtimes.py +0 -0
  20. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/analysis/pace.py +0 -0
  21. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/analysis/selfdescribe.py +0 -0
  22. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/analysis/suggest.py +0 -0
  23. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/analysis/use_or_lose.py +0 -0
  24. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/attribute.py +0 -0
  25. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/chat_format.py +0 -0
  26. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/cli.py +0 -0
  27. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/collectors/__init__.py +0 -0
  28. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/collectors/bailian.py +0 -0
  29. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/collectors/base.py +0 -0
  30. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/collectors/caut.py +0 -0
  31. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/collectors/clinepass.py +0 -0
  32. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/collectors/codexbar.py +0 -0
  33. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/collectors/cswap.py +0 -0
  34. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/collectors/grok.py +0 -0
  35. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/collectors/hermes.py +0 -0
  36. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/collectors/opencode_go.py +0 -0
  37. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/collectors/opencode_zen.py +0 -0
  38. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/collectors/openrouter.py +0 -0
  39. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/collectors/openusage.py +0 -0
  40. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/collectors/openusage_sh.py +0 -0
  41. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/collectors/qwencloud.py +0 -0
  42. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/collectors/runner.py +0 -0
  43. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/collectors/tokscale.py +0 -0
  44. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/config.py +0 -0
  45. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/ledger.py +0 -0
  46. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/macos_trust.py +0 -0
  47. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/models.py +0 -0
  48. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/sampler.py +0 -0
  49. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/secretspec.py +0 -0
  50. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/serve.py +0 -0
  51. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/tty.py +0 -0
  52. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/tui/__init__.py +0 -0
  53. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/tui/app.py +0 -0
  54. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/tui/builders.py +0 -0
  55. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/watch.py +0 -0
  56. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse.egg-info/SOURCES.txt +0 -0
  57. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse.egg-info/dependency_links.txt +0 -0
  58. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse.egg-info/entry_points.txt +0 -0
  59. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse.egg-info/requires.txt +0 -0
  60. {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse.egg-info/top_level.txt +0 -0
  61. {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_attribution.py +0 -0
  62. {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_bailian.py +0 -0
  63. {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_base_run_json.py +0 -0
  64. {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_caut_parse.py +0 -0
  65. {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_chat_format.py +0 -0
  66. {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_cli.py +0 -0
  67. {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_clinepass.py +0 -0
  68. {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_codexbar_parse.py +0 -0
  69. {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_config.py +0 -0
  70. {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_cswap_parse.py +0 -0
  71. {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_grok.py +0 -0
  72. {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_hermes_parse.py +0 -0
  73. {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_history.py +0 -0
  74. {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_install_deps.py +0 -0
  75. {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_local_runtimes.py +0 -0
  76. {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_macos_trust.py +0 -0
  77. {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_models_cli_binary.py +0 -0
  78. {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_models_pace.py +0 -0
  79. {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_opencode_go.py +0 -0
  80. {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_opencode_zen.py +0 -0
  81. {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_openrouter.py +0 -0
  82. {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_openusage_parse.py +0 -0
  83. {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_pace.py +0 -0
  84. {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_qwencloud.py +0 -0
  85. {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_release_script.py +0 -0
  86. {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_runner_consolidation.py +0 -0
  87. {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_selfdescribe.py +0 -0
  88. {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_serve.py +0 -0
  89. {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_shared_quota_semantics.py +0 -0
  90. {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_snapshots_persistence.py +0 -0
  91. {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_suggest.py +0 -0
  92. {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_tokscale_parse.py +0 -0
  93. {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_tty.py +0 -0
  94. {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_tui.py +0 -0
  95. {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_use_or_lose.py +0 -0
  96. {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_watch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aiuse
3
- Version: 3.2.4
3
+ Version: 3.2.6
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aiuse"
7
- version = "3.2.4"
7
+ version = "3.2.6"
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.4"
3
+ __version__ = "3.2.6"
@@ -6,8 +6,9 @@ There are two live transports with mutual failover:
6
6
  1. Bearer (AIUSE_MUSE_API_KEY / META_API_KEY / secretspec / ~/.config/muse/auth.json
7
7
  from `muse login`) → https://api.meta.ai/v1/*
8
8
  2. Cookie (AIUSE_MUSE_COOKIE / secretspec MUSE_COOKIE from `aiuse credential refresh muse --from chrome`)
9
- → GET https://dev.meta.ai/usage (scrape LSD + fb_dtsg + team_id) → POST https://dev.meta.ai/api/graphql/
10
- doc_id 9128374650192834 (MuseDevBillingBalanceQuery) → billing_info {balance, credit_limit, remaining_budget}
9
+ → GET https://dev.meta.ai/api/auth/me + /api/portal/teams/{id}/billing-banner and /usage.
10
+ The pre-2026-10 Relay page (LSD + fb_dtsg on /usage) is only a fallback when the portal
11
+ routes are absent. https://dev.meta.ai/usage now redirects to the public marketing page.
11
12
 
12
13
  If one transport is absent or fails, the other is tried. Absent both → [] . 401/403 with a
13
14
  credential present surfaces as AccountUsage(error=…) only after both transports fail.
@@ -26,8 +27,10 @@ import re
26
27
  import shutil
27
28
  import subprocess
28
29
  from collections.abc import Mapping
30
+ from datetime import date
29
31
  from pathlib import Path
30
32
  from typing import Any
33
+ from urllib.parse import quote, urlencode
31
34
 
32
35
  import requests
33
36
 
@@ -61,6 +64,17 @@ _TEAM_ENV = "AIUSE_MUSE_TEAM_ID"
61
64
  _DEV_HOME_URL = "https://dev.meta.ai/"
62
65
  _DEV_USAGE_URL = "https://dev.meta.ai/usage"
63
66
  _GRAPHQL_URL = "https://dev.meta.ai/api/graphql/"
67
+ _PORTAL_ORIGIN = "https://dev.meta.ai"
68
+ _SIGN_IN_URL = "https://dev.meta.ai/api/auth/login"
69
+ # Shown when Chrome still holds a dead llm_sess. /usage is a marketing redirect and
70
+ # no longer embeds fb_dtsg; muse.ai is the separate Muse chat app.
71
+ _SESSION_REJECTED = (
72
+ "Muse cookie: dev.meta.ai rejected this Chrome session. "
73
+ f"Open {_SIGN_IN_URL} in this Chrome profile and wait until the Model API "
74
+ "dashboard loads, then re-run `aiuse credential refresh muse --from chrome`. "
75
+ "Signing in at https://muse.ai does not create this session, and "
76
+ "https://dev.meta.ai/usage no longer embeds fb_dtsg."
77
+ )
64
78
  # Live Relay persisted query (LLMDCBillingBannerContainerQuery). Meta rotates these;
65
79
  # free_money_* amounts use PECurrency DEFAULT_AMOUNT_OFFSET=100 (cents for USD).
66
80
  _BILLING_DOC_ID = "28281552291474266"
@@ -229,18 +243,36 @@ def _soft_inventory_account(*, account: str | None) -> AccountUsage:
229
243
  billing_kind=BillingKind.PAYG_API,
230
244
  notes=[
231
245
  "Muse API key accepted (/models); Meta does not expose a billing endpoint on api.meta.ai yet.",
232
- "For live balance: sign into https://dev.meta.ai in Chrome, then run "
246
+ "For live balance: sign into https://dev.meta.ai/api/auth/login in Chrome, then run "
233
247
  "`aiuse credential refresh muse --from chrome`.",
234
248
  ],
235
249
  )
236
250
 
237
251
 
252
+ class _PortalUnavailable(Exception):
253
+ """Portal routes are missing, so the legacy HTML scrape may still work."""
254
+
255
+
238
256
  def _collect_via_cookie(
239
257
  cookie: str,
240
258
  env: Mapping[str, str],
241
259
  timeout: float,
242
260
  *,
243
261
  account: str | None = None,
262
+ ) -> list[AccountUsage]:
263
+ """Read Model API billing. Portal JSON first; Relay HTML only if those routes are gone."""
264
+ try:
265
+ return _collect_via_portal(cookie, env, timeout, account=account)
266
+ except _PortalUnavailable:
267
+ return _collect_via_legacy_cookie(cookie, env, timeout, account=account)
268
+
269
+
270
+ def _collect_via_legacy_cookie(
271
+ cookie: str,
272
+ env: Mapping[str, str],
273
+ timeout: float,
274
+ *,
275
+ account: str | None = None,
244
276
  ) -> list[AccountUsage]:
245
277
  # Prefer explicit team_id (env / operator URL). Bare /usage without team_id redirects to auth.
246
278
  team_id = str(env.get(_TEAM_ENV) or "").strip()
@@ -306,6 +338,298 @@ def _collect_via_cookie(
306
338
  return accounts
307
339
 
308
340
 
341
+ def _portal_headers(cookie: str) -> dict[str, str]:
342
+ return {
343
+ "Cookie": cookie,
344
+ "User-Agent": _USER_AGENT,
345
+ "Accept": "application/json",
346
+ "Referer": f"{_PORTAL_ORIGIN}/",
347
+ }
348
+
349
+
350
+ def _portal_get(cookie: str, path: str, timeout: float, *, missing_ok: bool = False) -> Any | None:
351
+ """GET a Model API portal route.
352
+
353
+ 401/403 and a login redirect are a dead Chrome session. 404 or a non-JSON
354
+ body means this deployment has no portal (legacy Relay scrape may apply),
355
+ unless ``missing_ok`` treats 404 as an absent optional route.
356
+ """
357
+ url = f"{_PORTAL_ORIGIN}{path}"
358
+ try:
359
+ resp = requests.get(
360
+ url,
361
+ timeout=timeout,
362
+ allow_redirects=True,
363
+ headers=_portal_headers(cookie),
364
+ )
365
+ except requests.RequestException as exc:
366
+ raise CollectorError(f"Muse cookie: failed to fetch {path}: {exc.__class__.__name__}") from exc
367
+ final = resp.url or ""
368
+ if resp.status_code in (401, 403) or "auth.meta.com" in final:
369
+ raise CollectorError(_SESSION_REJECTED)
370
+ if resp.status_code == 404:
371
+ if missing_ok:
372
+ return None
373
+ raise _PortalUnavailable()
374
+ try:
375
+ resp.raise_for_status()
376
+ except requests.HTTPError as exc:
377
+ raise CollectorError(f"Muse cookie: dev.meta.ai portal returned HTTP {resp.status_code} for {path}") from exc
378
+ try:
379
+ payload = resp.json()
380
+ except (ValueError, AttributeError) as exc:
381
+ if missing_ok:
382
+ return None
383
+ raise _PortalUnavailable() from exc
384
+ if not isinstance(payload, (dict, list)):
385
+ if missing_ok:
386
+ return None
387
+ raise _PortalUnavailable()
388
+ return payload
389
+
390
+
391
+ def _team_id_of(team: Any) -> str | None:
392
+ if not isinstance(team, dict):
393
+ return None
394
+ for key in ("id", "team_id", "teamId"):
395
+ value = team.get(key)
396
+ if value is not None and str(value).strip():
397
+ return str(value).strip()
398
+ return None
399
+
400
+
401
+ def _pick_team_id(teams: list[Any], wanted: str) -> str:
402
+ ids = [team_id for team in teams if (team_id := _team_id_of(team))]
403
+ if wanted:
404
+ return wanted
405
+ if not ids:
406
+ raise CollectorError(
407
+ "Muse cookie: Model API session has no team id. "
408
+ f"Open {_SIGN_IN_URL} and finish signing in, or set AIUSE_MUSE_TEAM_ID."
409
+ )
410
+ return ids[0]
411
+
412
+
413
+ def _iana_timezone() -> str:
414
+ try:
415
+ parts = Path("/etc/localtime").resolve().parts
416
+ except OSError:
417
+ return "UTC"
418
+ if "zoneinfo" not in parts:
419
+ return "UTC"
420
+ name = "/".join(parts[parts.index("zoneinfo") + 1 :])
421
+ return name or "UTC"
422
+
423
+
424
+ def _minor_to_usd(raw: Any, *, offset: float = 100.0) -> float | None:
425
+ if isinstance(raw, bool) or raw is None:
426
+ return None
427
+ if isinstance(raw, (int, float)):
428
+ return float(raw) / offset
429
+ if isinstance(raw, str):
430
+ try:
431
+ return float(raw) / offset
432
+ except ValueError:
433
+ return None
434
+ return None
435
+
436
+
437
+ def _free_remaining_usd(banner: Any) -> float | None:
438
+ """Free-credit remaining from the portal billing banner, in dollars."""
439
+ if not isinstance(banner, dict):
440
+ return None
441
+ kind = banner.get("kind")
442
+ if kind == "free_untouched":
443
+ return _minor_to_usd(banner.get("grant_minor_units"))
444
+ if kind == "free_partial":
445
+ return _minor_to_usd(banner.get("remaining_minor_units"))
446
+ return None
447
+
448
+
449
+ def _usage_point_usd(point: dict[str, Any], offset: float) -> float | None:
450
+ # Dashboard formula for USAGE_BILLABLE_COST: amount is minor units
451
+ # (currency.offset, default 100). Missing amount falls back to value/1e8.
452
+ if point.get("amount") is not None:
453
+ return _minor_to_usd(point.get("amount"), offset=offset)
454
+ if point.get("value") is not None:
455
+ scaled = _minor_to_usd(point.get("value"), offset=1e8)
456
+ if scaled is None:
457
+ return None
458
+ return scaled / offset
459
+ return None
460
+
461
+
462
+ def _mtd_from_portal_usage(payload: Any) -> float | None:
463
+ """Sum TOTAL billable cost for the current calendar month, in dollars."""
464
+ if not isinstance(payload, dict):
465
+ return None
466
+ series = payload.get("series")
467
+ if not isinstance(series, list):
468
+ return None
469
+ currency = payload.get("currency") if isinstance(payload.get("currency"), dict) else {}
470
+ offset = currency.get("offset") if isinstance(currency, dict) else None
471
+ if not isinstance(offset, (int, float)) or isinstance(offset, bool) or offset < 1:
472
+ offset = 100.0
473
+ prefix = date.today().strftime("%Y-%m")
474
+ totals = [row for row in series if isinstance(row, dict) and row.get("type") == "TOTAL"]
475
+ rows = totals or [row for row in series if isinstance(row, dict)]
476
+ saw_point = False
477
+ found = False
478
+ mtd = 0.0
479
+ for row in rows:
480
+ points = row.get("data_points")
481
+ if not isinstance(points, list):
482
+ continue
483
+ for point in points:
484
+ if not isinstance(point, dict):
485
+ continue
486
+ saw_point = True
487
+ day = str(point.get("date") or "")
488
+ if not day.startswith(prefix):
489
+ continue
490
+ amount = _usage_point_usd(point, float(offset))
491
+ if amount is None:
492
+ continue
493
+ mtd += amount
494
+ found = True
495
+ if not saw_point:
496
+ return None
497
+ return mtd if found else 0.0
498
+
499
+
500
+ def _account_label_from_me(me: Any, account: str | None) -> str | None:
501
+ if account:
502
+ return account
503
+ if not isinstance(me, dict):
504
+ return None
505
+ candidates: list[Any] = [me, me.get("user") if isinstance(me.get("user"), dict) else None]
506
+ for obj in candidates:
507
+ if not isinstance(obj, dict):
508
+ continue
509
+ for key in ("email", "user_email", "name"):
510
+ value = obj.get(key)
511
+ if isinstance(value, str) and value.strip():
512
+ return value.strip()
513
+ return None
514
+
515
+
516
+ def _accounts_from_portal(
517
+ banner: Any,
518
+ spend_usd: float | None,
519
+ *,
520
+ team_id: str,
521
+ usage_raw: Any,
522
+ ) -> list[AccountUsage]:
523
+ free_remaining = _free_remaining_usd(banner)
524
+ notes = [
525
+ "Live data fetched directly from Muse (dev.meta.ai portal).",
526
+ f"Endpoint: {_PORTAL_ORIGIN}/api/portal/teams/{team_id}/usage",
527
+ "Muse dashboard balance is cumulative PAYG spend (counts up from $0), "
528
+ "not prepaid remaining (DeepSeek / oc-zen count down to $0).",
529
+ ]
530
+ raw: dict[str, Any] = {"team_id": team_id, "banner": banner}
531
+ if usage_raw is not None:
532
+ raw["usage"] = usage_raw
533
+ if isinstance(banner, dict) and banner.get("kind"):
534
+ notes.append(f"Muse billing banner: {banner.get('kind')}.")
535
+ if spend_usd is not None:
536
+ notes.append(f"Muse spend this month: ${spend_usd:.2f} (counts up).")
537
+ return [
538
+ AccountUsage(
539
+ source="muse",
540
+ provider="muse",
541
+ billing_kind=BillingKind.PAYG_API,
542
+ balance_usd=None,
543
+ usage_credits=UsageCredits(used=spend_usd, currency="USD"),
544
+ notes=notes,
545
+ raw=raw,
546
+ )
547
+ ]
548
+ if free_remaining is not None and free_remaining > 0:
549
+ notes.append(f"Muse free credits remaining: ${free_remaining:.2f} (counts down).")
550
+ return [
551
+ AccountUsage(
552
+ source="muse",
553
+ provider="muse",
554
+ billing_kind=BillingKind.PREPAID_BALANCE,
555
+ balance_usd=free_remaining,
556
+ notes=notes,
557
+ raw=raw,
558
+ )
559
+ ]
560
+ raise CollectorError(
561
+ "Muse cookie: could not read MTD spend and free credits are empty; "
562
+ "re-run `aiuse credential refresh muse --from chrome`"
563
+ )
564
+
565
+
566
+ def _collect_via_portal(
567
+ cookie: str,
568
+ env: Mapping[str, str],
569
+ timeout: float,
570
+ *,
571
+ account: str | None = None,
572
+ ) -> list[AccountUsage]:
573
+ me = _portal_get(cookie, "/api/auth/me", timeout)
574
+ teams_payload = _portal_get(cookie, "/api/portal/teams", timeout)
575
+ if isinstance(teams_payload, dict):
576
+ teams = teams_payload.get("teams")
577
+ elif isinstance(teams_payload, list):
578
+ teams = teams_payload
579
+ else:
580
+ teams = None
581
+ if not isinstance(teams, list) or not teams:
582
+ raise CollectorError(
583
+ "Muse cookie: Model API session returned no teams. "
584
+ f"Open {_SIGN_IN_URL} until the dashboard loads, then re-run "
585
+ "`aiuse credential refresh muse --from chrome`."
586
+ )
587
+ wanted = str(env.get(_TEAM_ENV) or "").strip()
588
+ team_id = _pick_team_id(teams, wanted)
589
+ team_path = quote(team_id, safe="")
590
+ try:
591
+ banner = _portal_get(
592
+ cookie,
593
+ f"/api/portal/teams/{team_path}/billing-banner",
594
+ timeout,
595
+ missing_ok=True,
596
+ )
597
+ except CollectorError as exc:
598
+ if str(exc) == _SESSION_REJECTED:
599
+ raise
600
+ banner = None
601
+ today = date.today()
602
+ query = urlencode(
603
+ {
604
+ "metric": "USAGE_BILLABLE_COST",
605
+ "start_date": today.replace(day=1).isoformat(),
606
+ "end_date": today.isoformat(),
607
+ "timezone": _iana_timezone(),
608
+ }
609
+ )
610
+ usage_raw: Any = None
611
+ spend_usd: float | None = None
612
+ try:
613
+ usage_raw = _portal_get(
614
+ cookie,
615
+ f"/api/portal/teams/{team_path}/usage?{query}",
616
+ timeout,
617
+ missing_ok=True,
618
+ )
619
+ spend_usd = _mtd_from_portal_usage(usage_raw)
620
+ except CollectorError as exc:
621
+ if str(exc) == _SESSION_REJECTED:
622
+ raise
623
+ spend_usd = None
624
+ label = _account_label_from_me(me, account)
625
+ accounts = _accounts_from_portal(banner, spend_usd, team_id=team_id, usage_raw=usage_raw)
626
+ if label:
627
+ for row in accounts:
628
+ if not row.account:
629
+ row.account = label
630
+ return accounts
631
+
632
+
309
633
  def _fetch_dev_session_html(cookie: str, timeout: float, *, team_id: str | None = None) -> str:
310
634
  """Load a logged-in Model API HTML shell (LSD/DTSG + optional team_id scrape)."""
311
635
  urls: list[str] = []
@@ -327,33 +651,20 @@ def _fetch_dev_session_html(cookie: str, timeout: float, *, team_id: str | None
327
651
  },
328
652
  )
329
653
  if resp.status_code in (401, 403):
330
- raise CollectorError(
331
- f"Muse cookie rejected by dev.meta.ai (HTTP {resp.status_code}); "
332
- "re-run `aiuse credential refresh muse --from chrome`"
333
- )
654
+ raise CollectorError(_SESSION_REJECTED)
334
655
  resp.raise_for_status()
335
656
  text = resp.text
336
657
  # auth.meta.com login waterfall is not usable
337
658
  if "auth.meta.com" in (resp.url or ""):
338
- last_error = CollectorError(
339
- "Muse cookie: dev.meta.ai returned a login/error shell; open "
340
- "https://dev.meta.ai/usage in Chrome so llm_sess is present, then re-run "
341
- "`aiuse credential refresh muse --from chrome`"
342
- )
659
+ last_error = CollectorError(_SESSION_REJECTED)
343
660
  continue
344
661
  if _extract_dtsg(text) or _extract_lsd(text):
345
662
  return text
346
663
  # Tiny Error shells without tokens are not usable
347
664
  if len(text) < 5000:
348
- last_error = CollectorError(
349
- "Muse cookie: dev.meta.ai returned a login/error shell; open "
350
- "https://dev.meta.ai/usage in Chrome so llm_sess is present, then re-run "
351
- "`aiuse credential refresh muse --from chrome`"
352
- )
665
+ last_error = CollectorError(_SESSION_REJECTED)
353
666
  continue
354
- last_error = CollectorError(
355
- "Muse cookie: fetched HTML without fb_dtsg/LSD; re-run `aiuse credential refresh muse --from chrome`"
356
- )
667
+ last_error = CollectorError(_SESSION_REJECTED)
357
668
  except CollectorError as exc:
358
669
  last_error = exc
359
670
  except requests.RequestException as exc:
@@ -34,6 +34,10 @@ _OPENCODE_COOKIE_SECRET = "OPENCODE_ZEN_COOKIE"
34
34
  _MUSE = "muse"
35
35
  _MUSE_HOSTS = ("dev.meta.ai", "meta.ai", "facebook.com", "auth.meta.com")
36
36
  _MUSE_COOKIE_SECRET = "MUSE_COOKIE"
37
+ # llama_dev_sess is the current Model API dashboard cookie (host dev.meta.ai).
38
+ # llm_sess and dh_sess are older names. Facebook c_user/xs and muse.ai hatch_sess
39
+ # do not authorize https://dev.meta.ai.
40
+ _MUSE_SESSION_COOKIES = frozenset({"llama_dev_sess", "llm_sess", "dh_sess"})
37
41
  _CHROME_ROOT = Path.home() / "Library/Application Support/Google/Chrome"
38
42
  _DEFAULT_TIMEOUT_S = 10.0
39
43
 
@@ -238,14 +242,19 @@ def _chrome_cookie_header_for_muse(profile: str) -> str:
238
242
  pairs.append(f"{item.name}={value}")
239
243
  if not pairs:
240
244
  raise CredentialError("no Muse cookies were found; sign in to dev.meta.ai in the selected Chrome profile")
241
- # Model API auth is llm_sess on .dev.meta.ai (not Facebook c_user/xs).
242
- if "llm_sess" not in names:
245
+ _require_muse_session_cookie(names)
246
+ return "; ".join(pairs)
247
+
248
+
249
+ def _require_muse_session_cookie(names: set[str]) -> None:
250
+ """Reject a Chrome jar that has no Model API session cookie."""
251
+ if _MUSE_SESSION_COOKIES.isdisjoint(names):
243
252
  raise CredentialError(
244
- "Chrome is missing the Muse session cookie (llm_sess). "
245
- "In this Chrome profile open https://dev.meta.ai/usage until the usage "
246
- "dashboard loads, then re-run `aiuse credential refresh muse --from chrome`."
253
+ "Chrome is missing a Model API session cookie (llama_dev_sess, llm_sess, or dh_sess). "
254
+ "Open https://dev.meta.ai/api/auth/login in this Chrome profile and wait "
255
+ "until the dashboard loads, then re-run `aiuse credential refresh muse --from chrome`. "
256
+ "https://muse.ai is the Muse chat app and does not sign in the Model API."
247
257
  )
248
- return "; ".join(pairs)
249
258
 
250
259
 
251
260
  def _validate_muse_cookie(cookie: str, *, timeout: float) -> None:
@@ -1398,6 +1398,25 @@ def _cell_color(s: _Style, used: float) -> Any:
1398
1398
  return s.green
1399
1399
 
1400
1400
 
1401
+ def _style_clock_percent(s: _Style, cell: _ClockCell, *, width: int) -> str:
1402
+ """Right-align ``NNu/NNl`` into ``width`` and color only the left token.
1403
+
1404
+ The used side (``44u/``) stays the default color. The left side (``56l``
1405
+ or ``56l+``) is one colored token, so the ``l`` does not drop out between
1406
+ the number and the reset span. An inferred clock is dim end to end.
1407
+ Visible width is always ``width``: a short pair is padded on the left,
1408
+ and the color codes are not part of that width, so the next column starts
1409
+ on the same character in every row.
1410
+ """
1411
+ padded = f"{_clock_percent_text(cell):>{width}}"
1412
+ if cell.inferred:
1413
+ return s.dim(padded)
1414
+ slash = padded.rfind("/")
1415
+ if slash < 0:
1416
+ return _cell_color(s, cell.used_percent)(padded)
1417
+ return padded[: slash + 1] + _cell_color(s, cell.used_percent)(padded[slash + 1 :])
1418
+
1419
+
1401
1420
  def render_clock_matrix(
1402
1421
  alerts: list[UseOrLoseAlert],
1403
1422
  *,
@@ -1511,33 +1530,12 @@ def render_clock_matrix(
1511
1530
  continue
1512
1531
  any_inferred = any_inferred or cell.inferred
1513
1532
  any_folded = any_folded or bool(cell.folded)
1514
- percent = _clock_percent_text(cell)
1515
1533
  span = _format_reset_span(
1516
1534
  cell.days_until_reset,
1517
1535
  estimated=cell.reset_estimated,
1518
1536
  compact=layout.compact_deadline,
1519
1537
  )
1520
- # Split percent into used part (before "/") and left part (after "/" but before "l")
1521
- # Format: "44u/56l" or "44u/56l+" if folded
1522
- # Keep used part and slash in normal text, color only the left part
1523
- if "/" in percent:
1524
- used_part, rest = percent.split("/", 1)
1525
- # rest is like "56l" or "56l+"
1526
- left_part = rest.rstrip("l+")
1527
- mark = "+" if "+" in rest else ""
1528
- if cell.inferred:
1529
- colored_used = s.dim(used_part)
1530
- colored_left = s.dim(left_part)
1531
- else:
1532
- colored_used = used_part # Normal text for used part
1533
- colored_left = _cell_color(s, cell.used_percent)(left_part)
1534
- colored = f"{colored_used}/{colored_left}l{mark}"
1535
- else:
1536
- colored = (
1537
- s.dim(f"{percent:>{w_pct}}")
1538
- if cell.inferred
1539
- else _cell_color(s, cell.used_percent)(f"{percent:>{w_pct}}")
1540
- )
1538
+ colored = _style_clock_percent(s, cell, width=w_pct)
1541
1539
  if span is None:
1542
1540
  tail.append(colored + (" " * (w_clock - w_pct)))
1543
1541
  continue
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aiuse
3
- Version: 3.2.4
3
+ Version: 3.2.6
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
@@ -5,7 +5,7 @@ from pathlib import Path
5
5
 
6
6
  from aiuse import cli
7
7
  from aiuse.collectors.opencode_zen import _SESSION_COOKIE
8
- from aiuse.credentials import CredentialError, _cookie_header_for_opencode
8
+ from aiuse.credentials import CredentialError, _cookie_header_for_opencode, _require_muse_session_cookie
9
9
 
10
10
 
11
11
  def _cookie(name: str, value: str, domain: str) -> Cookie:
@@ -63,6 +63,25 @@ def test_cookie_header_rejects_missing_opencode_cookie():
63
63
  raise AssertionError("unrelated browser cookies must never be accepted")
64
64
 
65
65
 
66
+ def test_muse_session_accepts_the_current_dashboard_cookie():
67
+ _require_muse_session_cookie({"llama_dev_sess"})
68
+
69
+
70
+ def test_muse_session_still_accepts_older_cookie_names():
71
+ _require_muse_session_cookie({"llm_sess"})
72
+ _require_muse_session_cookie({"dh_sess"})
73
+
74
+
75
+ def test_muse_chat_and_facebook_cookies_are_not_a_model_api_session():
76
+ try:
77
+ _require_muse_session_cookie({"hatch_sess", "c_user", "xs"})
78
+ except CredentialError as exc:
79
+ assert "llama_dev_sess" in str(exc)
80
+ assert "https://muse.ai" in str(exc)
81
+ else:
82
+ raise AssertionError("muse.ai and Facebook cookies must not pass the Model API gate")
83
+
84
+
66
85
  def test_credential_refresh_dry_run_validates_without_writing(monkeypatch, capsys):
67
86
  validated: list[str] = []
68
87
  monkeypatch.setattr("aiuse.credentials._chrome_cookie_header", lambda _profile: "session=secret")
@@ -463,6 +463,23 @@ def test_collect_via_cookie_uses_team_id_and_spend_query(monkeypatch):
463
463
  }
464
464
 
465
465
  def fake_get(url, timeout, allow_redirects=True, headers=None):
466
+ if "/api/" in url:
467
+ # Portal routes absent: collector falls back to the Relay HTML scrape.
468
+ class NoPortal:
469
+ status_code = 404
470
+ text = ""
471
+ headers = {"content-type": "text/html"}
472
+
473
+ def __init__(self, final_url: str):
474
+ self.url = final_url
475
+
476
+ def raise_for_status(self):
477
+ pass
478
+
479
+ def json(self):
480
+ raise ValueError("no portal")
481
+
482
+ return NoPortal(url)
466
483
  assert "team_id=1483959756871752" in url
467
484
  return FakeGet()
468
485
 
@@ -487,3 +504,104 @@ def test_collect_via_cookie_uses_team_id_and_spend_query(monkeypatch):
487
504
  muse_mod._BILLING_DOC_ID,
488
505
  muse_mod._SPEND_DOC_ID,
489
506
  }
507
+
508
+
509
+ def _json_response(payload, *, status_code=200, url="https://dev.meta.ai/api/auth/me"):
510
+ class Response:
511
+ headers = {"content-type": "application/json"}
512
+ text = json.dumps(payload)
513
+
514
+ def __init__(self):
515
+ self.status_code = status_code
516
+ self.url = url
517
+
518
+ def raise_for_status(self):
519
+ if self.status_code >= 400:
520
+ raise requests.HTTPError(response=self)
521
+
522
+ def json(self):
523
+ return payload
524
+
525
+ return Response()
526
+
527
+
528
+ def test_collect_via_cookie_portal_spend_and_session_label(monkeypatch):
529
+ from datetime import date
530
+
531
+ from aiuse.collectors import muse as muse_mod
532
+
533
+ day = date.today().isoformat()
534
+ seen: list[str] = []
535
+
536
+ def fake_get(url, timeout, allow_redirects=True, headers=None):
537
+ seen.append(url)
538
+ if url.endswith("/api/auth/me"):
539
+ return _json_response({"email": "muse-user@example.com"}, url=url)
540
+ if url.endswith("/api/portal/teams"):
541
+ return _json_response({"teams": [{"id": "1483959756871752", "name": "personal"}]}, url=url)
542
+ if url.endswith("/billing-banner"):
543
+ return _json_response(
544
+ {"kind": "free_partial", "remaining_minor_units": 250, "currency": "USD"},
545
+ url=url,
546
+ )
547
+ if "/usage?" in url:
548
+ assert "metric=USAGE_BILLABLE_COST" in url
549
+ return _json_response(
550
+ {
551
+ "currency": {"code": "USD", "symbol": "$", "format": "{symbol}{amount}", "offset": 100},
552
+ "series": [
553
+ {
554
+ "type": "TOTAL",
555
+ "data_points": [{"date": day, "amount": "30"}],
556
+ }
557
+ ],
558
+ },
559
+ url=url,
560
+ )
561
+ raise AssertionError(url)
562
+
563
+ monkeypatch.setattr(requests, "get", fake_get)
564
+ accounts = muse_mod._collect_via_cookie("llm_sess=abc", {}, 5.0)
565
+ assert accounts[0].account == "muse-user@example.com"
566
+ assert accounts[0].balance_usd is None
567
+ assert accounts[0].usage_credits is not None
568
+ assert accounts[0].usage_credits.used == 0.30
569
+ assert accounts[0].billing_kind == BillingKind.PAYG_API
570
+ assert any("/api/auth/me" in url for url in seen)
571
+ assert all("/api/graphql" not in url for url in seen)
572
+
573
+
574
+ def test_collect_via_cookie_portal_free_credits_when_usage_empty(monkeypatch):
575
+ from aiuse.collectors import muse as muse_mod
576
+
577
+ def fake_get(url, timeout, allow_redirects=True, headers=None):
578
+ if url.endswith("/api/auth/me"):
579
+ return _json_response({"email": "muse-user@example.com"}, url=url)
580
+ if url.endswith("/api/portal/teams"):
581
+ return _json_response({"teams": [{"id": "99"}]}, url=url)
582
+ if url.endswith("/billing-banner"):
583
+ return _json_response({"kind": "free_untouched", "grant_minor_units": 500}, url=url)
584
+ if "/usage?" in url:
585
+ return _json_response({"series": []}, url=url)
586
+ raise AssertionError(url)
587
+
588
+ monkeypatch.setattr(requests, "get", fake_get)
589
+ accounts = muse_mod._collect_via_cookie("dh_sess=abc", {}, 5.0)
590
+ assert accounts[0].balance_usd == 5.0
591
+ assert accounts[0].usage_credits is None
592
+ assert accounts[0].billing_kind == BillingKind.PREPAID_BALANCE
593
+
594
+
595
+ def test_collect_via_cookie_rejected_session_does_not_scrape_html(monkeypatch):
596
+ from aiuse.collectors import muse as muse_mod
597
+
598
+ seen: list[str] = []
599
+
600
+ def fake_get(url, timeout, allow_redirects=True, headers=None):
601
+ seen.append(url)
602
+ return _json_response({"error": "Not authenticated"}, status_code=401, url=url)
603
+
604
+ monkeypatch.setattr(requests, "get", fake_get)
605
+ with pytest.raises(CollectorError, match="api/auth/login"):
606
+ muse_mod._collect_via_cookie("llm_sess=stale", {}, 5.0)
607
+ assert seen == ["https://dev.meta.ai/api/auth/me"]
@@ -1916,6 +1916,88 @@ def test_clock_matrix_omits_slash_when_clock_has_no_timestamp():
1916
1916
  assert "0u/100l" in tokens
1917
1917
 
1918
1918
 
1919
+ def test_clock_matrix_pads_percents_and_colors_only_the_left_token():
1920
+ """A short ``4u/96l`` must not slide the next clock left of a ``79u/21l``.
1921
+
1922
+ Color wraps the whole left token (``21l``), not just the digits, and the
1923
+ used side stays plain. Stripping ANSI leaves the plain table unchanged.
1924
+ """
1925
+ now = utcnow()
1926
+
1927
+ def window(label: str, used: float, left: float, *, window_minutes: int, **when: float) -> QuotaWindow:
1928
+ return QuotaWindow(
1929
+ label=label,
1930
+ used_percent=used,
1931
+ remaining_percent=left,
1932
+ resets_at=now + timedelta(**when),
1933
+ window_minutes=window_minutes,
1934
+ )
1935
+
1936
+ snap = Snapshot(
1937
+ collected_at=now,
1938
+ accounts=[
1939
+ AccountUsage(
1940
+ provider="claude",
1941
+ source="cswap",
1942
+ account="me@gmail.com",
1943
+ billing_kind=BillingKind.SUBSCRIPTION_WINDOW,
1944
+ windows=[
1945
+ window("5-hour", 79.0, 21.0, window_minutes=300, hours=1, minutes=52),
1946
+ window("weekly", 56.0, 44.0, window_minutes=10080, days=5, hours=8),
1947
+ ],
1948
+ ),
1949
+ AccountUsage(
1950
+ provider="cursor",
1951
+ source="codexbar",
1952
+ account="me@gmail.com",
1953
+ billing_kind=BillingKind.SUBSCRIPTION_WINDOW,
1954
+ windows=[
1955
+ window("5-hour", 4.0, 96.0, window_minutes=300, minutes=36),
1956
+ window("weekly", 29.0, 71.0, window_minutes=10080, days=3, hours=2),
1957
+ ],
1958
+ ),
1959
+ ],
1960
+ )
1961
+ plain = render_clock_matrix([], snapshot=snap, color=False, width=160)
1962
+ colored = render_clock_matrix([], snapshot=snap, color=True, width=160)
1963
+ assert _strip_ansi(colored) == plain
1964
+
1965
+ rows = {line.split()[2]: line for line in plain.splitlines() if line[:5].strip() in _BAND_TAGS}
1966
+ claude = rows["claude"]
1967
+ cursor = rows["cursor"]
1968
+ assert claude.index("56u/44l") == cursor.index("29u/71l")
1969
+ assert claude.index("79u/21l") == cursor.index("4u/96l") - 1
1970
+ header = plain.splitlines()[0]
1971
+ assert header.index("5H") + 1 == claude.index("79u/21l") + len("79u/21l") - 1
1972
+ assert header.index("WEEK") + 3 == claude.index("56u/44l") + len("56u/44l") - 1
1973
+
1974
+ # First data row has no zebra stripe, so the reset is a plain ``[0m``.
1975
+ # The next row's stripe rewrites that reset; the open code is what matters.
1976
+ assert "79u/\033[33m21l\033[0m" in colored
1977
+ assert "\033[32m96l" in colored
1978
+ assert "\033[33m79u" not in colored
1979
+ assert "\033[32m4u" not in colored
1980
+
1981
+
1982
+ def test_style_clock_percent_pads_and_keeps_fold_mark_in_the_left_token():
1983
+ from aiuse.report import _ClockCell, _style_clock_percent
1984
+
1985
+ on = _Style(True)
1986
+ short = _style_clock_percent(on, _ClockCell(used_percent=4.0), width=8)
1987
+ assert _strip_ansi(short) == " 4u/96l"
1988
+ assert short == " 4u/\033[32m96l\033[0m"
1989
+
1990
+ folded = _style_clock_percent(on, _ClockCell(used_percent=56.0, folded=2), width=8)
1991
+ assert _strip_ansi(folded) == "56u/44l+"
1992
+ assert folded == "56u/\033[36m44l+\033[0m"
1993
+
1994
+ inferred = _style_clock_percent(on, _ClockCell(used_percent=4.0, inferred=True), width=8)
1995
+ assert inferred == on.dim(" 4u/96l")
1996
+
1997
+ off = _Style(False)
1998
+ assert _style_clock_percent(off, _ClockCell(used_percent=4.0), width=8) == " 4u/96l"
1999
+
2000
+
1919
2001
  @pytest.mark.parametrize(
1920
2002
  ("provider", "display"),
1921
2003
  [
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