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.
- {aiuse-3.2.4/src/aiuse.egg-info → aiuse-3.2.6}/PKG-INFO +1 -1
- {aiuse-3.2.4 → aiuse-3.2.6}/pyproject.toml +1 -1
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/__init__.py +1 -1
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/collectors/muse.py +331 -20
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/credentials.py +15 -6
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/report.py +20 -22
- {aiuse-3.2.4 → aiuse-3.2.6/src/aiuse.egg-info}/PKG-INFO +1 -1
- {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_credentials.py +20 -1
- {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_muse.py +118 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_report.py +82 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/LICENSE +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/README.md +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/setup.cfg +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/__main__.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/agent_notes.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/ai_stub.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/analysis/__init__.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/analysis/history.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/analysis/local_runtimes.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/analysis/pace.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/analysis/selfdescribe.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/analysis/suggest.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/analysis/use_or_lose.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/attribute.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/chat_format.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/cli.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/collectors/__init__.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/collectors/bailian.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/collectors/base.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/collectors/caut.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/collectors/clinepass.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/collectors/codexbar.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/collectors/cswap.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/collectors/grok.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/collectors/hermes.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/collectors/opencode_go.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/collectors/opencode_zen.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/collectors/openrouter.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/collectors/openusage.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/collectors/openusage_sh.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/collectors/qwencloud.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/collectors/runner.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/collectors/tokscale.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/config.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/ledger.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/macos_trust.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/models.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/sampler.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/secretspec.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/serve.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/tty.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/tui/__init__.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/tui/app.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/tui/builders.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse/watch.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse.egg-info/SOURCES.txt +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse.egg-info/dependency_links.txt +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse.egg-info/entry_points.txt +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse.egg-info/requires.txt +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/src/aiuse.egg-info/top_level.txt +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_attribution.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_bailian.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_base_run_json.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_caut_parse.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_chat_format.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_cli.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_clinepass.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_codexbar_parse.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_config.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_cswap_parse.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_grok.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_hermes_parse.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_history.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_install_deps.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_local_runtimes.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_macos_trust.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_models_cli_binary.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_models_pace.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_opencode_go.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_opencode_zen.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_openrouter.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_openusage_parse.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_pace.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_qwencloud.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_release_script.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_runner_consolidation.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_selfdescribe.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_serve.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_shared_quota_semantics.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_snapshots_persistence.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_suggest.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_tokscale_parse.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_tty.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_tui.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_use_or_lose.py +0 -0
- {aiuse-3.2.4 → aiuse-3.2.6}/tests/test_watch.py +0 -0
|
@@ -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/
|
|
10
|
-
|
|
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
|
-
|
|
242
|
-
|
|
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
|
|
245
|
-
"
|
|
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
|
-
|
|
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
|
|
@@ -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
|
|
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
|