@0dai-dev/cli 4.3.6 → 4.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -11
- package/bin/0dai.js +133 -33
- package/lib/ai/manifest/mcp-exposure-contract.json +121 -0
- package/lib/ai/meta/manifest/mcp-tool-tiers.json +435 -0
- package/lib/ai/registry/mcp-catalog.json +98 -0
- package/lib/commands/auth.js +2 -1
- package/lib/commands/compliance.js +1 -1
- package/lib/commands/doctor.js +707 -12
- package/lib/commands/experience.js +40 -5
- package/lib/commands/feedback.js +157 -15
- package/lib/commands/gh.js +26 -0
- package/lib/commands/graph.js +9 -4
- package/lib/commands/heatmap.js +1 -1
- package/lib/commands/init.js +298 -27
- package/lib/commands/mcp.js +111 -33
- package/lib/commands/models.js +138 -41
- package/lib/commands/play.js +20 -4
- package/lib/commands/provider.js +30 -59
- package/lib/commands/quota.js +1 -1
- package/lib/commands/receipt.js +1 -1
- package/lib/commands/run.js +14 -6
- package/lib/commands/runner.js +31 -1
- package/lib/commands/status.js +176 -11
- package/lib/commands/swarm.js +130 -12
- package/lib/commands/trust.js +1 -1
- package/lib/commands/update.js +184 -38
- package/lib/commands/usage.js +1 -1
- package/lib/commands/validate.js +32 -3
- package/lib/commands/vault.js +43 -8
- package/lib/python/__init__.py +0 -0
- package/lib/python/agent_quotas.py +525 -0
- package/lib/python/anomaly_alert.py +397 -0
- package/lib/python/anti_pattern_detector.py +799 -0
- package/lib/python/auth.py +443 -0
- package/lib/python/capi_profile_guard.py +477 -0
- package/lib/python/compliance_report.py +581 -0
- package/lib/python/drift_detector.py +388 -0
- package/lib/python/experience_pipeline.py +1130 -0
- package/lib/python/graph.py +19 -0
- package/lib/python/graph_core.py +293 -0
- package/lib/python/graph_io.py +179 -0
- package/lib/python/graph_legacy.py +2052 -0
- package/lib/python/graph_legacy_helpers.py +221 -0
- package/lib/python/graph_outcomes_core.py +85 -0
- package/lib/python/graph_queries.py +171 -0
- package/lib/python/graph_slice.py +198 -0
- package/lib/python/graph_slicer.py +576 -0
- package/lib/python/graph_slicer_cli.py +60 -0
- package/lib/python/graph_validation.py +64 -0
- package/lib/python/heatmap.py +943 -0
- package/lib/python/json_utils.py +193 -0
- package/lib/python/mcp_exposure_check.py +247 -0
- package/lib/python/model_router.py +1434 -0
- package/lib/python/project_manager.py +621 -0
- package/lib/python/provider_profiles.py +1618 -0
- package/lib/python/provider_registry.py +1211 -0
- package/lib/python/provider_registry_cli.py +125 -0
- package/lib/python/receipt_png.py +727 -0
- package/lib/python/structural_memory.py +325 -0
- package/lib/python/swarm_cost.py +177 -0
- package/lib/python/usage_ledger.py +569 -0
- package/lib/scripts/mcp_tier_config.py +240 -0
- package/lib/shared.js +96 -12
- package/lib/tui/index.mjs +35174 -0
- package/lib/utils/activation_telemetry.js +1 -4
- package/lib/utils/constants.js +7 -1
- package/lib/utils/identity.js +184 -0
- package/lib/utils/mcp-auth.js +81 -15
- package/lib/utils/plan.js +1 -1
- package/lib/vault/index.js +19 -3
- package/lib/vault/storage.js +21 -2
- package/lib/wizard.js +5 -2
- package/package.json +9 -3
- package/scripts/build-python-bundle.js +106 -0
- package/scripts/build-tui.js +14 -1
- package/scripts/harvest_experience.py +523 -0
- package/scripts/postinstall.js +15 -9
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""0dai Usage Ledger — per-request cost accounting tied to billing plans.
|
|
3
|
+
|
|
4
|
+
The ledger bridges three existing systems:
|
|
5
|
+
1. model_usage_log.py — raw token counts per agent invocation
|
|
6
|
+
2. swarm_cost.py — USD cost estimates per model
|
|
7
|
+
3. tier_gate.py — daily swarm task quotas per billing plan
|
|
8
|
+
4. billing_state.py — payment records and plan entitlements
|
|
9
|
+
|
|
10
|
+
It produces:
|
|
11
|
+
- ai/telemetry/usage-ledger.jsonl — append-only row per billing event
|
|
12
|
+
- ai/telemetry/usage-ledger-daily.json — daily rollup (cost, tasks, by-model)
|
|
13
|
+
- ai/telemetry/usage-ledger-monthly.json — monthly rollup for billing cycles
|
|
14
|
+
|
|
15
|
+
CLI:
|
|
16
|
+
python3 usage_ledger.py record --task-id <id> --agent <agent> --model <model> \
|
|
17
|
+
--tokens-input <n> --tokens-output <n> [--plan <plan>]
|
|
18
|
+
python3 usage_ledger.py daily [--date YYYY-MM-DD] [--json]
|
|
19
|
+
python3 usage_ledger.py monthly [--month YYYY-MM] [--json]
|
|
20
|
+
python3 usage_ledger.py status [--plan <plan>] [--json]
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import datetime as _dt
|
|
26
|
+
import hashlib
|
|
27
|
+
import json
|
|
28
|
+
import pathlib
|
|
29
|
+
import sys
|
|
30
|
+
import time
|
|
31
|
+
from dataclasses import dataclass, asdict
|
|
32
|
+
|
|
33
|
+
SCRIPTS_DIR = pathlib.Path(__file__).resolve().parent
|
|
34
|
+
sys.path.insert(0, str(SCRIPTS_DIR))
|
|
35
|
+
|
|
36
|
+
import swarm_cost as sc # noqa: E402
|
|
37
|
+
|
|
38
|
+
LEDGER_DIR = pathlib.Path("ai/telemetry")
|
|
39
|
+
LEDGER_FILE = LEDGER_DIR / "usage-ledger.jsonl"
|
|
40
|
+
LEDGER_DAILY = LEDGER_DIR / "usage-ledger-daily.json"
|
|
41
|
+
LEDGER_MONTHLY = LEDGER_DIR / "usage-ledger-monthly.json"
|
|
42
|
+
|
|
43
|
+
# Free tier defaults from ai/work/strategy/free-tier-spec.md.
|
|
44
|
+
FREE_TIER_LIMITS = {
|
|
45
|
+
"monthly_merges": 10,
|
|
46
|
+
"monthly_tokens": 100_000,
|
|
47
|
+
"concurrent_agents": 2,
|
|
48
|
+
"api_requests_per_hour": 60,
|
|
49
|
+
"storage_kg_entries": 500,
|
|
50
|
+
"team_members": 1,
|
|
51
|
+
"reset_policy": "calendar_month_utc",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
# Plan entitlements (mirrors tier_gate.py PLAN_LIMITS for task budgets).
|
|
55
|
+
PLAN_ENTITLEMENTS = {
|
|
56
|
+
"free": {"daily_tasks": 0, "monthly_budget_usd": 0.0, "max_model_tier": "fast", "free_tier": FREE_TIER_LIMITS},
|
|
57
|
+
"pro": {"daily_tasks": 50, "monthly_budget_usd": 15.0, "max_model_tier": "balanced"},
|
|
58
|
+
"team": {"daily_tasks": 200, "monthly_budget_usd": 49.0, "max_model_tier": "deep"},
|
|
59
|
+
"enterprise": {"daily_tasks": 999999, "monthly_budget_usd": 999999.0, "max_model_tier": "deep"},
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# Model tier mapping
|
|
63
|
+
MODEL_TIERS = {
|
|
64
|
+
"fast": {"haiku", "gpt-5.4-mini", "gemini-1.5-flash"},
|
|
65
|
+
"balanced": {"sonnet", "gpt-5.3", "gpt-5.4", "gemini-3", "gemini-2.5-pro"},
|
|
66
|
+
"deep": {"opus", "gpt-5.5", "gpt-5.3-codex", "claude-opus-4.7", "gemini-3.1-pro"},
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
# Reverse lookup: model name -> tier
|
|
70
|
+
_MODEL_TO_TIER: dict[str, str] = {}
|
|
71
|
+
for tier, models in MODEL_TIERS.items():
|
|
72
|
+
for m in models:
|
|
73
|
+
_MODEL_TO_TIER[m] = tier
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _get_model_tier(model: str) -> str:
|
|
77
|
+
"""Return the cost tier for a model name."""
|
|
78
|
+
# Normalize
|
|
79
|
+
normalized = model.lower().strip()
|
|
80
|
+
if normalized in _MODEL_TO_TIER:
|
|
81
|
+
return _MODEL_TO_TIER[normalized]
|
|
82
|
+
# Fallback: check rate aliases
|
|
83
|
+
for tier, models in MODEL_TIERS.items():
|
|
84
|
+
for m in models:
|
|
85
|
+
if m in normalized or normalized in m:
|
|
86
|
+
return tier
|
|
87
|
+
return "balanced" # default assumption
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class LedgerEntry:
|
|
92
|
+
"""A single billing event."""
|
|
93
|
+
ts: str # ISO-8601 UTC
|
|
94
|
+
task_id: str # Swarm task ID or loop run ID
|
|
95
|
+
agent: str # claude, codex, gemini, loop, etc.
|
|
96
|
+
model: str # Model name
|
|
97
|
+
model_tier: str # fast, balanced, deep
|
|
98
|
+
tokens_input: int
|
|
99
|
+
tokens_output: int
|
|
100
|
+
tokens_total: int
|
|
101
|
+
cost_usd: float # Computed from swarm_cost rates
|
|
102
|
+
plan: str # free, pro, team, enterprise
|
|
103
|
+
auth_mode: str # api-key, subscription
|
|
104
|
+
source: str # swarm, loop, manual, adhoc
|
|
105
|
+
source_hash: str # Dedup hash
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _load_ledger() -> list[dict]:
|
|
109
|
+
if not LEDGER_FILE.is_file():
|
|
110
|
+
return []
|
|
111
|
+
entries = []
|
|
112
|
+
with LEDGER_FILE.open() as f:
|
|
113
|
+
for line in f:
|
|
114
|
+
line = line.strip()
|
|
115
|
+
if line:
|
|
116
|
+
try:
|
|
117
|
+
entries.append(json.loads(line))
|
|
118
|
+
except json.JSONDecodeError:
|
|
119
|
+
continue
|
|
120
|
+
return entries
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _append_entry(entry: dict) -> None:
|
|
124
|
+
LEDGER_DIR.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
with LEDGER_FILE.open("a") as f:
|
|
126
|
+
f.write(json.dumps(entry) + "\n")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _source_hash(task_id: str, agent: str, ts: str) -> str:
|
|
130
|
+
"""Short SHA-256 for dedup."""
|
|
131
|
+
return hashlib.sha256(f"{task_id}:{agent}:{ts}".encode()).hexdigest()[:12]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def compute_cost(model: str, tokens_input: int, tokens_output: int) -> float:
|
|
135
|
+
"""Compute USD cost using swarm_cost rates."""
|
|
136
|
+
try:
|
|
137
|
+
rate = sc.DEFAULT_RATES.get(model)
|
|
138
|
+
if rate:
|
|
139
|
+
input_rate, output_rate = rate
|
|
140
|
+
cost = (tokens_input / 1_000_000) * input_rate + (tokens_output / 1_000_000) * output_rate
|
|
141
|
+
return round(cost, 6)
|
|
142
|
+
# Fallback: estimate using sonnet rates
|
|
143
|
+
input_rate, output_rate = sc.DEFAULT_RATES.get("sonnet", (3.0, 15.0))
|
|
144
|
+
cost = (tokens_input / 1_000_000) * input_rate + (tokens_output / 1_000_000) * output_rate
|
|
145
|
+
return round(cost, 6)
|
|
146
|
+
except Exception: # noqa: BLE001 — cost estimation failed
|
|
147
|
+
return 0.0
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def record_event(
|
|
151
|
+
task_id: str,
|
|
152
|
+
agent: str,
|
|
153
|
+
model: str,
|
|
154
|
+
tokens_input: int,
|
|
155
|
+
tokens_output: int,
|
|
156
|
+
plan: str = "pro",
|
|
157
|
+
auth_mode: str = "subscription",
|
|
158
|
+
source: str = "swarm",
|
|
159
|
+
) -> LedgerEntry:
|
|
160
|
+
"""Record a usage event in the ledger."""
|
|
161
|
+
ts = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
162
|
+
model_tier = _get_model_tier(model)
|
|
163
|
+
cost = compute_cost(model, tokens_input, tokens_output)
|
|
164
|
+
tokens_total = tokens_input + tokens_output
|
|
165
|
+
shash = _source_hash(task_id, agent, ts)
|
|
166
|
+
|
|
167
|
+
entry = LedgerEntry(
|
|
168
|
+
ts=ts,
|
|
169
|
+
task_id=task_id,
|
|
170
|
+
agent=agent,
|
|
171
|
+
model=model,
|
|
172
|
+
model_tier=model_tier,
|
|
173
|
+
tokens_input=tokens_input,
|
|
174
|
+
tokens_output=tokens_output,
|
|
175
|
+
tokens_total=tokens_total,
|
|
176
|
+
cost_usd=cost,
|
|
177
|
+
plan=plan,
|
|
178
|
+
auth_mode=auth_mode,
|
|
179
|
+
source=source,
|
|
180
|
+
source_hash=shash,
|
|
181
|
+
)
|
|
182
|
+
entry_dict = asdict(entry)
|
|
183
|
+
_append_entry(entry_dict)
|
|
184
|
+
_update_daily_rollup(entry_dict)
|
|
185
|
+
_update_monthly_rollup(entry_dict)
|
|
186
|
+
return entry
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _update_daily_rollup(entry: dict) -> None:
|
|
190
|
+
"""Update the daily rollup file."""
|
|
191
|
+
daily = _load_daily_rollup()
|
|
192
|
+
date = entry["ts"][:10] # YYYY-MM-DD
|
|
193
|
+
day = daily.setdefault(date, {
|
|
194
|
+
"date": date,
|
|
195
|
+
"total_cost_usd": 0.0,
|
|
196
|
+
"total_tasks": 0,
|
|
197
|
+
"total_tokens": 0,
|
|
198
|
+
"by_model": {},
|
|
199
|
+
"by_agent": {},
|
|
200
|
+
"by_plan": {},
|
|
201
|
+
"by_source": {},
|
|
202
|
+
})
|
|
203
|
+
day["total_cost_usd"] = round(day["total_cost_usd"] + entry["cost_usd"], 6)
|
|
204
|
+
day["total_tasks"] += 1
|
|
205
|
+
day["total_tokens"] += entry["tokens_total"]
|
|
206
|
+
|
|
207
|
+
# By model
|
|
208
|
+
model_stats = day["by_model"].setdefault(entry["model"], {"tasks": 0, "cost_usd": 0.0, "tokens": 0})
|
|
209
|
+
model_stats["tasks"] += 1
|
|
210
|
+
model_stats["cost_usd"] = round(model_stats["cost_usd"] + entry["cost_usd"], 6)
|
|
211
|
+
model_stats["tokens"] += entry["tokens_total"]
|
|
212
|
+
|
|
213
|
+
# By agent
|
|
214
|
+
agent_stats = day["by_agent"].setdefault(entry["agent"], {"tasks": 0, "cost_usd": 0.0})
|
|
215
|
+
agent_stats["tasks"] += 1
|
|
216
|
+
agent_stats["cost_usd"] = round(agent_stats["cost_usd"] + entry["cost_usd"], 6)
|
|
217
|
+
|
|
218
|
+
# By plan
|
|
219
|
+
plan_stats = day["by_plan"].setdefault(entry["plan"], {"tasks": 0, "cost_usd": 0.0})
|
|
220
|
+
plan_stats["tasks"] += 1
|
|
221
|
+
plan_stats["cost_usd"] = round(plan_stats["cost_usd"] + entry["cost_usd"], 6)
|
|
222
|
+
|
|
223
|
+
# By source
|
|
224
|
+
source_stats = day["by_source"].setdefault(entry["source"], {"tasks": 0, "cost_usd": 0.0, "tokens": 0})
|
|
225
|
+
source_stats["tasks"] += 1
|
|
226
|
+
source_stats["cost_usd"] = round(source_stats["cost_usd"] + entry["cost_usd"], 6)
|
|
227
|
+
source_stats["tokens"] += entry["tokens_total"]
|
|
228
|
+
|
|
229
|
+
_save_daily_rollup(daily)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _update_monthly_rollup(entry: dict) -> None:
|
|
233
|
+
"""Update the monthly rollup file."""
|
|
234
|
+
monthly = _load_monthly_rollup()
|
|
235
|
+
month = entry["ts"][:7] # YYYY-MM
|
|
236
|
+
mon = monthly.setdefault(month, {
|
|
237
|
+
"month": month,
|
|
238
|
+
"total_cost_usd": 0.0,
|
|
239
|
+
"total_tasks": 0,
|
|
240
|
+
"total_tokens": 0,
|
|
241
|
+
"daily_count": 0,
|
|
242
|
+
"avg_daily_cost_usd": 0.0,
|
|
243
|
+
"by_model": {},
|
|
244
|
+
"by_plan": {},
|
|
245
|
+
"by_source": {},
|
|
246
|
+
})
|
|
247
|
+
mon["total_cost_usd"] = round(mon["total_cost_usd"] + entry["cost_usd"], 6)
|
|
248
|
+
mon["total_tasks"] += 1
|
|
249
|
+
mon["total_tokens"] += entry["tokens_total"]
|
|
250
|
+
mon["daily_count"] = len(set(e[:10] for e in _get_daily_keys()))
|
|
251
|
+
|
|
252
|
+
# Recompute averages
|
|
253
|
+
days_in_month = max(1, mon["daily_count"])
|
|
254
|
+
mon["avg_daily_cost_usd"] = round(mon["total_cost_usd"] / days_in_month, 6)
|
|
255
|
+
|
|
256
|
+
# By model
|
|
257
|
+
model_stats = mon["by_model"].setdefault(entry["model"], {"tasks": 0, "cost_usd": 0.0, "tokens": 0})
|
|
258
|
+
model_stats["tasks"] += 1
|
|
259
|
+
model_stats["cost_usd"] = round(model_stats["cost_usd"] + entry["cost_usd"], 6)
|
|
260
|
+
model_stats["tokens"] += entry["tokens_total"]
|
|
261
|
+
|
|
262
|
+
# By plan
|
|
263
|
+
plan_stats = mon["by_plan"].setdefault(entry["plan"], {"tasks": 0, "cost_usd": 0.0})
|
|
264
|
+
plan_stats["tasks"] += 1
|
|
265
|
+
plan_stats["cost_usd"] = round(plan_stats["cost_usd"] + entry["cost_usd"], 6)
|
|
266
|
+
|
|
267
|
+
# By source
|
|
268
|
+
source_stats = mon["by_source"].setdefault(entry["source"], {"tasks": 0, "cost_usd": 0.0, "tokens": 0})
|
|
269
|
+
source_stats["tasks"] += 1
|
|
270
|
+
source_stats["cost_usd"] = round(source_stats["cost_usd"] + entry["cost_usd"], 6)
|
|
271
|
+
source_stats["tokens"] += entry["tokens_total"]
|
|
272
|
+
|
|
273
|
+
_save_monthly_rollup(monthly)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _load_daily_rollup() -> dict:
|
|
277
|
+
if not LEDGER_DAILY.is_file():
|
|
278
|
+
return {}
|
|
279
|
+
try:
|
|
280
|
+
return json.loads(LEDGER_DAILY.read_text("utf-8"))
|
|
281
|
+
except (json.JSONDecodeError, OSError):
|
|
282
|
+
return {}
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _save_daily_rollup(data: dict) -> None:
|
|
286
|
+
LEDGER_DIR.mkdir(parents=True, exist_ok=True)
|
|
287
|
+
LEDGER_DAILY.write_text(json.dumps(data, indent=2), "utf-8")
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _load_monthly_rollup() -> dict:
|
|
291
|
+
if not LEDGER_MONTHLY.is_file():
|
|
292
|
+
return {}
|
|
293
|
+
try:
|
|
294
|
+
return json.loads(LEDGER_MONTHLY.read_text("utf-8"))
|
|
295
|
+
except (json.JSONDecodeError, OSError):
|
|
296
|
+
return {}
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _save_monthly_rollup(data: dict) -> None:
|
|
300
|
+
LEDGER_DIR.mkdir(parents=True, exist_ok=True)
|
|
301
|
+
LEDGER_MONTHLY.write_text(json.dumps(data, indent=2), "utf-8")
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _get_daily_keys() -> list[str]:
|
|
305
|
+
daily = _load_daily_rollup()
|
|
306
|
+
return list(daily.keys())
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def get_daily_summary(date: str | None = None) -> dict:
|
|
310
|
+
"""Get daily usage summary."""
|
|
311
|
+
if date is None:
|
|
312
|
+
date = _dt.date.today().isoformat()
|
|
313
|
+
daily = _load_daily_rollup()
|
|
314
|
+
return daily.get(date, {"date": date, "total_cost_usd": 0.0, "total_tasks": 0, "total_tokens": 0, "by_model": {}, "by_agent": {}, "by_plan": {}, "by_source": {}})
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def get_monthly_summary(month: str | None = None) -> dict:
|
|
318
|
+
"""Get monthly usage summary."""
|
|
319
|
+
if month is None:
|
|
320
|
+
month = _dt.date.today().strftime("%Y-%m")
|
|
321
|
+
monthly = _load_monthly_rollup()
|
|
322
|
+
return monthly.get(month, {"month": month, "total_cost_usd": 0.0, "total_tasks": 0, "total_tokens": 0, "daily_count": 0, "avg_daily_cost_usd": 0.0, "by_model": {}, "by_plan": {}, "by_source": {}})
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _next_month_reset(today: _dt.date) -> _dt.date:
|
|
326
|
+
"""Return the next calendar-month UTC reset date."""
|
|
327
|
+
if today.month == 12:
|
|
328
|
+
return _dt.date(today.year + 1, 1, 1)
|
|
329
|
+
return _dt.date(today.year, today.month + 1, 1)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _free_tier_meter(monthly: dict, today: _dt.date) -> dict:
|
|
333
|
+
"""Build the free-tier meter from monthly rollup data."""
|
|
334
|
+
limits = FREE_TIER_LIMITS.copy()
|
|
335
|
+
by_source = monthly.get("by_source", {})
|
|
336
|
+
merge_stats = by_source.get("merge", {})
|
|
337
|
+
merges_used = int(merge_stats.get("tasks", 0))
|
|
338
|
+
tokens_used = int(monthly.get("total_tokens", 0))
|
|
339
|
+
reset_at = _next_month_reset(today)
|
|
340
|
+
return {
|
|
341
|
+
"limits": limits,
|
|
342
|
+
"usage": {
|
|
343
|
+
"merges_used": merges_used,
|
|
344
|
+
"tokens_used": tokens_used,
|
|
345
|
+
},
|
|
346
|
+
"remaining": {
|
|
347
|
+
"merges": max(0, limits["monthly_merges"] - merges_used),
|
|
348
|
+
"tokens": max(0, limits["monthly_tokens"] - tokens_used),
|
|
349
|
+
},
|
|
350
|
+
"reset": {
|
|
351
|
+
"policy": limits["reset_policy"],
|
|
352
|
+
"reset_at": reset_at.isoformat(),
|
|
353
|
+
"days_remaining": max(0, (reset_at - today).days),
|
|
354
|
+
},
|
|
355
|
+
"cap_reached": merges_used >= limits["monthly_merges"] or tokens_used >= limits["monthly_tokens"],
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def get_plan_status(plan: str) -> dict:
|
|
360
|
+
"""Get current plan usage status with entitlements."""
|
|
361
|
+
entitlements = PLAN_ENTITLEMENTS.get(plan, PLAN_ENTITLEMENTS["pro"])
|
|
362
|
+
today = _dt.date.today().isoformat()
|
|
363
|
+
month = today[:7]
|
|
364
|
+
daily = get_daily_summary(today)
|
|
365
|
+
monthly = get_monthly_summary(month)
|
|
366
|
+
status = {
|
|
367
|
+
"plan": plan,
|
|
368
|
+
"entitlements": entitlements,
|
|
369
|
+
"today": {
|
|
370
|
+
"date": today,
|
|
371
|
+
"tasks_used": daily.get("total_tasks", 0),
|
|
372
|
+
"tasks_remaining": max(0, entitlements["daily_tasks"] - daily.get("total_tasks", 0)),
|
|
373
|
+
"cost_usd": daily.get("total_cost_usd", 0.0),
|
|
374
|
+
},
|
|
375
|
+
"this_month": {
|
|
376
|
+
"month": month,
|
|
377
|
+
"tasks_used": monthly.get("total_tasks", 0),
|
|
378
|
+
"cost_usd": monthly.get("total_cost_usd", 0.0),
|
|
379
|
+
"budget_remaining": round(max(0, entitlements["monthly_budget_usd"] - monthly.get("total_cost_usd", 0.0)), 2),
|
|
380
|
+
},
|
|
381
|
+
}
|
|
382
|
+
if plan == "free":
|
|
383
|
+
status["free_tier"] = _free_tier_meter(monthly, _dt.date.fromisoformat(today))
|
|
384
|
+
return status
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def check_budget_alerts(plan: str) -> list[dict]:
|
|
388
|
+
"""Check if usage is approaching budget limits. Returns alerts if any."""
|
|
389
|
+
status = get_plan_status(plan)
|
|
390
|
+
alerts = []
|
|
391
|
+
|
|
392
|
+
# Daily task usage
|
|
393
|
+
daily_limit = status["entitlements"]["daily_tasks"]
|
|
394
|
+
daily_pct = status["today"]["tasks_used"] / max(1, daily_limit)
|
|
395
|
+
if daily_limit > 0 and daily_pct >= 0.9:
|
|
396
|
+
alerts.append({
|
|
397
|
+
"severity": "warning" if daily_pct < 1.0 else "critical",
|
|
398
|
+
"type": "daily_tasks_near_limit",
|
|
399
|
+
"message": f"Daily tasks at {daily_pct * 100:.0f}% ({status['today']['tasks_used']}/{status['entitlements']['daily_tasks']})",
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
# Monthly budget
|
|
403
|
+
monthly_budget = status["entitlements"]["monthly_budget_usd"]
|
|
404
|
+
monthly_pct = status["this_month"]["cost_usd"] / max(0.01, monthly_budget)
|
|
405
|
+
if monthly_budget > 0 and monthly_pct >= 0.8:
|
|
406
|
+
alerts.append({
|
|
407
|
+
"severity": "warning" if monthly_pct < 1.0 else "critical",
|
|
408
|
+
"type": "monthly_budget_near_limit",
|
|
409
|
+
"message": f"Monthly budget at {monthly_pct * 100:.0f}% (${status['this_month']['cost_usd']:.2f}/${status['entitlements']['monthly_budget_usd']:.2f})",
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
free_tier = status.get("free_tier")
|
|
413
|
+
if free_tier:
|
|
414
|
+
limits = free_tier["limits"]
|
|
415
|
+
usage = free_tier["usage"]
|
|
416
|
+
merge_pct = usage["merges_used"] / max(1, limits["monthly_merges"])
|
|
417
|
+
token_pct = usage["tokens_used"] / max(1, limits["monthly_tokens"])
|
|
418
|
+
if merge_pct >= 0.8:
|
|
419
|
+
alerts.append({
|
|
420
|
+
"severity": "warning" if merge_pct < 1.0 else "critical",
|
|
421
|
+
"type": "free_merges_near_limit",
|
|
422
|
+
"message": f"Free tier merges at {merge_pct * 100:.0f}% ({usage['merges_used']}/{limits['monthly_merges']})",
|
|
423
|
+
})
|
|
424
|
+
if token_pct >= 0.8:
|
|
425
|
+
alerts.append({
|
|
426
|
+
"severity": "warning" if token_pct < 1.0 else "critical",
|
|
427
|
+
"type": "free_tokens_near_limit",
|
|
428
|
+
"message": f"Free tier tokens at {token_pct * 100:.0f}% ({usage['tokens_used']}/{limits['monthly_tokens']})",
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
return alerts
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def check_free_tier_merge_allowed(plan: str) -> tuple[bool, str]:
|
|
435
|
+
"""Return (allowed, message). Blocks when free tier merge/token cap reached."""
|
|
436
|
+
if plan != "free":
|
|
437
|
+
return True, ""
|
|
438
|
+
status = get_plan_status("free")
|
|
439
|
+
free_tier = status.get("free_tier") or {}
|
|
440
|
+
if free_tier.get("cap_reached"):
|
|
441
|
+
usage = free_tier.get("usage", {})
|
|
442
|
+
limits = free_tier.get("limits", {})
|
|
443
|
+
reset = free_tier.get("reset", {})
|
|
444
|
+
merges_used = usage.get("merges_used", "?")
|
|
445
|
+
merge_limit = limits.get("monthly_merges", 10)
|
|
446
|
+
days = reset.get("days_remaining", "?")
|
|
447
|
+
return False, (
|
|
448
|
+
f"BLOCKED: free tier cap reached ({merges_used}/{merge_limit} merges). "
|
|
449
|
+
f"Reset in {days}d. Upgrade: 0dai upgrade pro"
|
|
450
|
+
)
|
|
451
|
+
remaining = free_tier.get("remaining", {}).get("merges", 0)
|
|
452
|
+
if remaining == 1:
|
|
453
|
+
return True, "WARNING: 1 merge left this month on free tier"
|
|
454
|
+
return True, ""
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def main() -> int:
|
|
458
|
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
459
|
+
sub = parser.add_subparsers(dest="command")
|
|
460
|
+
|
|
461
|
+
# record
|
|
462
|
+
p_record = sub.add_parser("record", help="Record a usage event")
|
|
463
|
+
p_record.add_argument("--task-id", required=True)
|
|
464
|
+
p_record.add_argument("--agent", required=True)
|
|
465
|
+
p_record.add_argument("--model", required=True)
|
|
466
|
+
p_record.add_argument("--tokens-input", type=int, required=True)
|
|
467
|
+
p_record.add_argument("--tokens-output", type=int, required=True)
|
|
468
|
+
p_record.add_argument("--plan", default="pro")
|
|
469
|
+
p_record.add_argument("--auth-mode", default="subscription")
|
|
470
|
+
p_record.add_argument("--source", default="swarm")
|
|
471
|
+
p_record.add_argument("--json", dest="as_json", action="store_true")
|
|
472
|
+
|
|
473
|
+
# daily
|
|
474
|
+
p_daily = sub.add_parser("daily", help="Show daily usage summary")
|
|
475
|
+
p_daily.add_argument("--date", help="YYYY-MM-DD (default today)")
|
|
476
|
+
p_daily.add_argument("--json", dest="as_json", action="store_true")
|
|
477
|
+
|
|
478
|
+
# monthly
|
|
479
|
+
p_monthly = sub.add_parser("monthly", help="Show monthly usage summary")
|
|
480
|
+
p_monthly.add_argument("--month", help="YYYY-MM (default current month)")
|
|
481
|
+
p_monthly.add_argument("--json", dest="as_json", action="store_true")
|
|
482
|
+
|
|
483
|
+
# status
|
|
484
|
+
p_status = sub.add_parser("status", help="Show plan status and budget alerts")
|
|
485
|
+
p_status.add_argument("--plan", default="pro")
|
|
486
|
+
p_status.add_argument("--json", dest="as_json", action="store_true")
|
|
487
|
+
|
|
488
|
+
p_check = sub.add_parser("check-merge", help="Exit 0 if plan may perform a merge")
|
|
489
|
+
p_check.add_argument("--plan", default="pro")
|
|
490
|
+
|
|
491
|
+
args = parser.parse_args()
|
|
492
|
+
|
|
493
|
+
if args.command == "check-merge":
|
|
494
|
+
allowed, message = check_free_tier_merge_allowed(args.plan)
|
|
495
|
+
if message:
|
|
496
|
+
print(message, file=sys.stderr if not allowed else sys.stdout)
|
|
497
|
+
return 0 if allowed else 2
|
|
498
|
+
|
|
499
|
+
if args.command == "record":
|
|
500
|
+
entry = record_event(
|
|
501
|
+
task_id=args.task_id,
|
|
502
|
+
agent=args.agent,
|
|
503
|
+
model=args.model,
|
|
504
|
+
tokens_input=args.tokens_input,
|
|
505
|
+
tokens_output=args.tokens_output,
|
|
506
|
+
plan=args.plan,
|
|
507
|
+
auth_mode=args.auth_mode,
|
|
508
|
+
source=args.source,
|
|
509
|
+
)
|
|
510
|
+
if args.as_json:
|
|
511
|
+
print(json.dumps(asdict(entry), indent=2))
|
|
512
|
+
else:
|
|
513
|
+
print(f"[recorded] {entry.task_id} — {entry.model} ({entry.model_tier}) — {entry.tokens_total:,} tokens — ${entry.cost_usd:.6f}")
|
|
514
|
+
|
|
515
|
+
elif args.command == "daily":
|
|
516
|
+
summary = get_daily_summary(getattr(args, "date", None))
|
|
517
|
+
if args.as_json:
|
|
518
|
+
print(json.dumps(summary, indent=2))
|
|
519
|
+
else:
|
|
520
|
+
print(f"📅 {summary['date']} — {summary.get('total_tasks', 0)} tasks — ${summary.get('total_cost_usd', 0):.6f} — {summary.get('total_tokens', 0):,} tokens")
|
|
521
|
+
for model, stats in summary.get("by_model", {}).items():
|
|
522
|
+
print(f" {model}: {stats['tasks']} tasks — ${stats['cost_usd']:.6f}")
|
|
523
|
+
|
|
524
|
+
elif args.command == "monthly":
|
|
525
|
+
summary = get_monthly_summary(getattr(args, "month", None))
|
|
526
|
+
if args.as_json:
|
|
527
|
+
print(json.dumps(summary, indent=2))
|
|
528
|
+
else:
|
|
529
|
+
print(f"📆 {summary['month']} — {summary.get('total_tasks', 0)} tasks — ${summary.get('total_cost_usd', 0):.6f} — avg ${summary.get('avg_daily_cost_usd', 0):.6f}/day")
|
|
530
|
+
for model, stats in summary.get("by_model", {}).items():
|
|
531
|
+
print(f" {model}: {stats['tasks']} tasks — ${stats['cost_usd']:.6f}")
|
|
532
|
+
|
|
533
|
+
elif args.command == "status":
|
|
534
|
+
status = get_plan_status(args.plan)
|
|
535
|
+
alerts = check_budget_alerts(args.plan)
|
|
536
|
+
output = {"status": status, "alerts": alerts}
|
|
537
|
+
if args.as_json:
|
|
538
|
+
print(json.dumps(output, indent=2))
|
|
539
|
+
else:
|
|
540
|
+
print(f"Plan: {status['plan']}")
|
|
541
|
+
if status["entitlements"]["daily_tasks"] > 0:
|
|
542
|
+
print(f" Today: {status['today']['tasks_used']}/{status['entitlements']['daily_tasks']} tasks — ${status['today']['cost_usd']:.6f}")
|
|
543
|
+
else:
|
|
544
|
+
print(f" Today: {status['today']['tasks_used']} tasks — ${status['today']['cost_usd']:.6f}")
|
|
545
|
+
print(f" This month: {status['this_month']['tasks_used']} tasks — ${status['this_month']['cost_usd']:.2f}/${status['entitlements']['monthly_budget_usd']:.2f}")
|
|
546
|
+
if "free_tier" in status:
|
|
547
|
+
ft = status["free_tier"]
|
|
548
|
+
print(
|
|
549
|
+
" Free tier: "
|
|
550
|
+
f"{ft['remaining']['merges']}/{ft['limits']['monthly_merges']} merges remaining, "
|
|
551
|
+
f"{ft['remaining']['tokens']:,}/{ft['limits']['monthly_tokens']:,} tokens remaining"
|
|
552
|
+
)
|
|
553
|
+
print(f" Reset: {ft['reset']['reset_at']} UTC ({ft['reset']['days_remaining']} days)")
|
|
554
|
+
if alerts:
|
|
555
|
+
print(" ⚠️ Alerts:")
|
|
556
|
+
for a in alerts:
|
|
557
|
+
print(f" [{a['severity']}] {a['message']}")
|
|
558
|
+
else:
|
|
559
|
+
print(" ✅ Within budget")
|
|
560
|
+
|
|
561
|
+
else:
|
|
562
|
+
parser.print_help()
|
|
563
|
+
return 1
|
|
564
|
+
|
|
565
|
+
return 0
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
if __name__ == "__main__":
|
|
569
|
+
sys.exit(main())
|