EPIC R03 (Team Duy) — Model Providers & Routing. All six tasks done.
R03-T02 — Provider catalogue
domain/models/provider_descriptor.py ProviderDescriptor (frozen), WireProtocol, AuthKind
infrastructure/providers/provider_registry.py
thread-safe registry: id/alias lookup, dynamic
lookup by model id, adapter selection by protocol
providers/factory.py drops its own _REGISTRY table and delegates to the
registry, still raising ProviderError for callers
R03-T03 — RoutingApplicationService (pure Python, 4 modes)
application/model_routing/routing_models.py
RoutingMode (off/auto/manual/fallback),
RoutingRequest (immutable snapshot), RouteEvaluation,
RoutingOutcome
application/model_routing/routing_application_service.py
the single decision flow, reached through two narrow
ports plus a caller-supplied confirm callback, so no
Qt import is needed
application/model_routing/core_routing_adapter.py
binds the ports to core/routing and AppContext
Fallback is a new resilience mode: keep the selected model while it can serve the turn,
re-route only when it cannot. Wired end to end through config.py, state.py,
ui/routing_toggle.py and i18n.py (EN/JA/VI).
R03-T04 / T05 — Remove the duplicated routing flow
ui/chat_panel.py (#L638), ui/co4e_tab.py, ui/folder_tab.py each drop ~35 lines of copied
logic and call the shared service; the widgets now only build a RoutingRequest, host the
Manual-mode modal and render the outcome.
R03-T06 — Token usage as an event
infrastructure/telemetry/usage_sink.py UsageEvent + UsageEventSink protocol, with tracker,
in-memory and composite sinks
providers/openai_compat.py, providers/anthropic.py
publish a UsageEvent instead of writing to the
usage tracker themselves
core/usage_tracker.py adds current_context() so a sink can borrow and
restore a thread's attribution
R03-T01 — Contract tests
tests/contracts/test_providers.py parametrises over every provider in the registry: chat()
signature, canonical assistant message, normalised tool calls, response closed, tool schema
translation, ProviderError, list_models/test_connection, one UsageEvent per turn.
Test infrastructure fix (required to verify any of the above): tests/conftest.py used to put
the repository's PARENT directory on sys.path, so `import cowork_local.*` resolved against
whichever sibling folder happened to carry that name — on a dev machine, an unrelated older
checkout. The suite reported green while exercising different code. The conftest now binds
this checkout to the cowork_local name in sys.modules.
Verification
pytest tests/ 236 passed in ~1.8s (102 before this change)
scripts/check_imports.py PASS, 0 forbidden imports in domain/ and application/
new production files largest is 288 lines, all under the 400 LOC ceiling
new tests 134 (50 contract, 70 unit, 14 integration), all offline
scripts/run_quality_gate.py does not exist yet (R10-T02), so DoD item 7 was covered by
check_imports.py plus the full suite.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
537 lines
24 KiB
Python
537 lines
24 KiB
Python
"""Token-usage tracking for the Dashboard tab.
|
||
|
||
Every provider turn records one event (JSON line, one file per day under
|
||
``~/.cowork_local/usage/``): when, which tab/task ("source" + "label"),
|
||
provider/model, input/output/cached token counts. Real counts come from the
|
||
server's ``usage`` block when the stream includes one; otherwise a ~4 chars ≈
|
||
1 token estimate keeps the dashboard useful on gateways that never report
|
||
usage (events carry ``"estimated": true`` so the UI can say so).
|
||
|
||
The turn's source/label is set by the caller ON THE WORKER THREAD via
|
||
:func:`set_context` (thread-local — concurrent turns don't mix labels).
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import threading
|
||
from datetime import date, datetime
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
from ..config import CONFIG_DIR
|
||
|
||
USAGE_DIR = CONFIG_DIR / "usage"
|
||
|
||
_local = threading.local()
|
||
|
||
# Process-global identity (NOT thread-local — who's logged in and which
|
||
# machine this is are fixed for the whole process, set once right after
|
||
# login in app.py::run(), unlike source/label which vary per worker turn).
|
||
_identity_account = ""
|
||
_identity_machine = ""
|
||
_identity_shared_dir = ""
|
||
|
||
|
||
def set_identity(account: str, machine: str, shared_dir: str = "") -> None:
|
||
"""Called once after login succeeds. ``shared_dir``, when reachable,
|
||
makes every subsequent :func:`record` ALSO best-effort-append to the
|
||
shared cross-machine telemetry store (see :mod:`telemetry_shared`)."""
|
||
global _identity_account, _identity_machine, _identity_shared_dir
|
||
_identity_account = account or ""
|
||
_identity_machine = machine or ""
|
||
_identity_shared_dir = shared_dir or ""
|
||
|
||
|
||
def set_context(source: str, label: str = "") -> None:
|
||
"""Tag subsequent :func:`record` calls on THIS thread (e.g. ("cowork",
|
||
"chat title") / ("task", "task title"))."""
|
||
_local.source = source
|
||
_local.label = label
|
||
|
||
|
||
def current_context() -> tuple:
|
||
"""The ``(source, label)`` currently tagged on THIS thread.
|
||
|
||
Public counterpart to :func:`set_context`, added for
|
||
``infrastructure/telemetry/usage_sink.py``: a subscriber that needs to
|
||
attribute one event to a different surface must be able to save the
|
||
caller's context and put it back afterwards, instead of leaving the worker
|
||
thread permanently retagged.
|
||
"""
|
||
return getattr(_local, "source", "") or "", getattr(_local, "label", "") or ""
|
||
|
||
|
||
# ---- per-thread usage accumulator -----------------------------------------
|
||
# A step/run that wants to know its OWN token/cost (not the all-time file total)
|
||
# calls begin_accumulation(), reads accumulated() before/after a unit of work,
|
||
# and diffs the two. Because record() runs on the same worker thread that drives
|
||
# the work (providers are called synchronously inside it), the thread-local
|
||
# total is exactly that thread's usage — concurrent flows on other threads
|
||
# accumulate independently, with no locking or label collisions. Used by the
|
||
# Co4E runner to attach per-step token/cost to each node's output event.
|
||
def begin_accumulation() -> None:
|
||
"""Start (or reset) this thread's usage accumulator."""
|
||
_local.acc = {"in": 0, "out": 0, "cache": 0, "events": []}
|
||
|
||
|
||
def accumulated() -> Dict[str, Any]:
|
||
"""Snapshot of this thread's accumulated usage since :func:`begin_accumulation`
|
||
(all zeros / empty if never started). ``events`` is a per-turn list of
|
||
``{model, in, out}`` so a caller can price a delta with the per-model table."""
|
||
acc = getattr(_local, "acc", None)
|
||
if acc is None:
|
||
return {"in": 0, "out": 0, "cache": 0, "events": []}
|
||
return {"in": acc["in"], "out": acc["out"], "cache": acc["cache"],
|
||
"events": list(acc["events"])}
|
||
|
||
|
||
def end_accumulation() -> None:
|
||
"""Stop accumulating on this thread (subsequent records aren't tallied)."""
|
||
_local.acc = None
|
||
|
||
|
||
def estimate_tokens(text: str) -> int:
|
||
return max(0, len(text or "") // 4)
|
||
|
||
|
||
def record(provider: str, model: str, input_tokens: int, output_tokens: int,
|
||
cached_tokens: int = 0, estimated: bool = False) -> None:
|
||
"""Append one usage event. Never raises — usage tracking must never break
|
||
a chat turn."""
|
||
try:
|
||
now = datetime.now()
|
||
event = {
|
||
"ts": now.isoformat(timespec="seconds"),
|
||
"source": getattr(_local, "source", "") or "other",
|
||
"label": getattr(_local, "label", "") or "",
|
||
"provider": provider or "",
|
||
"model": model or "",
|
||
"in": int(input_tokens or 0),
|
||
"out": int(output_tokens or 0),
|
||
"cache": int(cached_tokens or 0),
|
||
"estimated": bool(estimated),
|
||
"account": _identity_account,
|
||
"machine": _identity_machine,
|
||
}
|
||
USAGE_DIR.mkdir(parents=True, exist_ok=True)
|
||
path = USAGE_DIR / f"{now.strftime('%Y-%m-%d')}.jsonl"
|
||
with path.open("a", encoding="utf-8") as f:
|
||
f.write(json.dumps(event, ensure_ascii=False) + "\n")
|
||
_write_shared(event, now)
|
||
# Feed this thread's live accumulator, if one is active (see above).
|
||
acc = getattr(_local, "acc", None)
|
||
if acc is not None:
|
||
acc["in"] += event["in"]
|
||
acc["out"] += event["out"]
|
||
acc["cache"] += event["cache"]
|
||
acc["events"].append({"model": event["model"], "in": event["in"], "out": event["out"]})
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
|
||
|
||
def _write_shared(event: Dict[str, Any], now: datetime) -> None:
|
||
"""Best-effort mirror of ``event`` into the shared cross-machine store —
|
||
one file PER MACHINE per day, so no two machines ever write the same
|
||
file (avoids any read-modify-write race). Never raises."""
|
||
if not _identity_shared_dir or not _identity_machine:
|
||
return
|
||
try:
|
||
shared = Path(_identity_shared_dir).expanduser() / "telemetry" / "usage"
|
||
shared.mkdir(parents=True, exist_ok=True)
|
||
path = shared / f"{_identity_machine}-{now.strftime('%Y-%m-%d')}.jsonl"
|
||
with path.open("a", encoding="utf-8") as f:
|
||
f.write(json.dumps(event, ensure_ascii=False) + "\n")
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
|
||
|
||
def load_events(start: Optional[date] = None, end: Optional[date] = None,
|
||
directory: Path = None) -> List[Dict[str, Any]]:
|
||
"""Events between ``start`` and ``end`` (inclusive; None = unbounded)."""
|
||
directory = directory or USAGE_DIR
|
||
if not directory.exists():
|
||
return []
|
||
events: List[Dict[str, Any]] = []
|
||
for path in sorted(directory.glob("*.jsonl")):
|
||
try:
|
||
day = datetime.strptime(path.stem, "%Y-%m-%d").date()
|
||
except ValueError:
|
||
continue
|
||
if (start and day < start) or (end and day > end):
|
||
continue
|
||
try:
|
||
for line in path.read_text(encoding="utf-8").splitlines():
|
||
if line.strip():
|
||
events.append(json.loads(line))
|
||
except (OSError, json.JSONDecodeError):
|
||
continue
|
||
return events
|
||
|
||
|
||
def summarize(events: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||
"""Aggregate a list of events into dashboard numbers + habit stats."""
|
||
total_in = sum(e.get("in", 0) for e in events)
|
||
total_out = sum(e.get("out", 0) for e in events)
|
||
total_cache = sum(e.get("cache", 0) for e in events)
|
||
by_label: Dict[str, int] = {}
|
||
by_source: Dict[str, int] = {}
|
||
by_hour: Dict[int, int] = {}
|
||
by_day: Dict[str, int] = {}
|
||
for e in events:
|
||
tok = e.get("in", 0) + e.get("out", 0)
|
||
key = e.get("label") or e.get("source") or "?"
|
||
by_label[key] = by_label.get(key, 0) + tok
|
||
by_source[e.get("source", "?")] = by_source.get(e.get("source", "?"), 0) + tok
|
||
try:
|
||
dt = datetime.fromisoformat(e.get("ts", ""))
|
||
by_hour[dt.hour] = by_hour.get(dt.hour, 0) + tok
|
||
by_day[dt.strftime("%Y-%m-%d")] = by_day.get(dt.strftime("%Y-%m-%d"), 0) + tok
|
||
except ValueError:
|
||
pass
|
||
return {
|
||
"turns": len(events),
|
||
"in": total_in, "out": total_out, "cache": total_cache,
|
||
"total": total_in + total_out,
|
||
"avg_per_turn": (total_in + total_out) // len(events) if events else 0,
|
||
"estimated_share": (sum(1 for e in events if e.get("estimated")) / len(events)
|
||
if events else 0.0),
|
||
"top_labels": sorted(by_label.items(), key=lambda kv: -kv[1])[:5],
|
||
"by_source": sorted(by_source.items(), key=lambda kv: -kv[1]),
|
||
"busiest_hour": max(by_hour.items(), key=lambda kv: kv[1])[0] if by_hour else None,
|
||
"busiest_day": max(by_day.items(), key=lambda kv: kv[1])[0] if by_day else None,
|
||
}
|
||
|
||
|
||
# ---- cost ------------------------------------------------------------------
|
||
DEFAULT_PRICING = {
|
||
"price_per_mtok_in_usd": 0.5, # USD per 1M input tokens (flat fallback rate)
|
||
"price_per_mtok_out_usd": 1.5, # USD per 1M output tokens
|
||
"price_per_mtok_cache_usd": 0.1, # USD per 1M cached tokens
|
||
"currency": "USD", # display currency: USD | VND | JPY
|
||
"usd_to_vnd": 25000.0,
|
||
"usd_to_jpy": 150.0,
|
||
# Per-model price table (USD / 1M tokens): {model: {"in","out","cache"}}.
|
||
# Events whose model has an entry are costed with ITS rates; everything
|
||
# else falls back to the flat price_per_mtok_* rates above. Edited in the
|
||
# Monitoring Overview's pricing table.
|
||
"model_prices": {},
|
||
# Reference URL of the price list the table was filled from (set in
|
||
# Settings; shown as a link beside the table — informational only, the
|
||
# app never scrapes it).
|
||
"pricing_url": "",
|
||
}
|
||
|
||
_CURRENCY_FMT = {"USD": ("$", 4), "VND": ("₫", 0), "JPY": ("¥", 1)}
|
||
|
||
# Currencies the display picker offers — exactly the ones format_cost() can
|
||
# actually convert to (symbol/precision above + a usd_to_* rate below).
|
||
SUPPORTED_CURRENCIES = tuple(_CURRENCY_FMT)
|
||
|
||
|
||
def cost_usd(summary: Dict[str, Any], pricing: Dict[str, Any]) -> Dict[str, float]:
|
||
p = {**DEFAULT_PRICING, **(pricing or {})}
|
||
return {
|
||
"in": summary.get("in", 0) / 1e6 * float(p["price_per_mtok_in_usd"]),
|
||
"out": summary.get("out", 0) / 1e6 * float(p["price_per_mtok_out_usd"]),
|
||
"cache": summary.get("cache", 0) / 1e6 * float(p["price_per_mtok_cache_usd"]),
|
||
}
|
||
|
||
|
||
def cost_usd_events(events: List[Dict[str, Any]], pricing: Dict[str, Any]) -> Dict[str, float]:
|
||
"""Per-bucket USD cost computed EVENT BY EVENT so the per-model price
|
||
table applies: an event whose ``model`` has an entry in
|
||
``pricing["model_prices"]`` is costed with that model's own rates; any
|
||
other event uses the flat ``price_per_mtok_*`` rates. With an empty
|
||
table this equals ``cost_usd(summarize(events), pricing)`` exactly."""
|
||
p = {**DEFAULT_PRICING, **(pricing or {})}
|
||
table = p.get("model_prices") or {}
|
||
flat = {"in": float(p["price_per_mtok_in_usd"]),
|
||
"out": float(p["price_per_mtok_out_usd"]),
|
||
"cache": float(p["price_per_mtok_cache_usd"])}
|
||
out = {"in": 0.0, "out": 0.0, "cache": 0.0}
|
||
for e in events:
|
||
rates = table.get(e.get("model", "")) or {}
|
||
for bucket in ("in", "out", "cache"):
|
||
try:
|
||
rate = float(rates.get(bucket, flat[bucket]))
|
||
except (TypeError, ValueError):
|
||
rate = flat[bucket]
|
||
out[bucket] += e.get(bucket, 0) / 1e6 * rate
|
||
return out
|
||
|
||
|
||
def bucketed_series(events: List[Dict[str, Any]], granularity: str = "day",
|
||
pricing: Dict[str, Any] = None, last: int = None) -> List[tuple]:
|
||
"""Group usage events into time buckets → ordered ``[(label, tokens, cost_usd)]``.
|
||
|
||
``granularity``: ``day`` (YYYY-MM-DD) · ``month`` (YYYY-MM) · ``year`` (YYYY).
|
||
``last`` keeps only the most recent N buckets (for the dashboard chart)."""
|
||
from collections import OrderedDict
|
||
pricing = pricing or {}
|
||
|
||
def _key(ts: Any) -> str:
|
||
s = str(ts or "")[:10]
|
||
if granularity == "year":
|
||
return s[:4]
|
||
if granularity == "month":
|
||
return s[:7]
|
||
return s
|
||
|
||
buckets: "OrderedDict[str, List[Dict[str, Any]]]" = OrderedDict()
|
||
for e in sorted(events, key=lambda ev: str(ev.get("ts", ""))):
|
||
k = _key(e.get("ts"))
|
||
if k:
|
||
buckets.setdefault(k, []).append(e)
|
||
out = []
|
||
for k, evs in buckets.items():
|
||
tokens = sum(int(e.get("in", 0) or 0) + int(e.get("out", 0) or 0)
|
||
+ int(e.get("cache", 0) or 0) for e in evs)
|
||
cost = sum(cost_usd_events(evs, pricing).values())
|
||
out.append((k, tokens, cost))
|
||
if last and len(out) > last:
|
||
out = out[-last:]
|
||
return out
|
||
|
||
|
||
def period_bounds(gran: str, offset: int, today: Optional[date] = None) -> tuple:
|
||
"""[start, end) dates of the period ``offset`` periods from the current one
|
||
(0 = current, -1 = the previous week/month/year). Weeks run Mon→Sun."""
|
||
from datetime import timedelta
|
||
today = today or date.today()
|
||
if gran == "week":
|
||
monday = today - timedelta(days=today.weekday()) # Monday of this week
|
||
start = monday + timedelta(weeks=offset)
|
||
return start, start + timedelta(days=7)
|
||
if gran == "year":
|
||
y = today.year + offset
|
||
return date(y, 1, 1), date(y + 1, 1, 1)
|
||
# month (default)
|
||
base = today.year * 12 + (today.month - 1) + offset
|
||
y, m = divmod(base, 12)
|
||
y2, m2 = divmod(base + 1, 12)
|
||
return date(y, m + 1, 1), date(y2, m2 + 1, 1)
|
||
|
||
|
||
def _period_label(gran: str, start: date) -> str:
|
||
if gran == "week":
|
||
return start.isoformat() # the week's Monday (YYYY-MM-DD)
|
||
if gran == "year":
|
||
return str(start.year)
|
||
return start.strftime("%Y-%m")
|
||
|
||
|
||
def _sum_between(events: List[Dict[str, Any]], start: date, end: date,
|
||
pricing: Dict[str, Any]) -> tuple:
|
||
lo, hi = start.isoformat(), end.isoformat()
|
||
evs = [e for e in events if lo <= str(e.get("ts", ""))[:10] < hi]
|
||
tokens = sum(int(e.get("in", 0) or 0) + int(e.get("out", 0) or 0)
|
||
+ int(e.get("cache", 0) or 0) for e in evs)
|
||
cost = sum(cost_usd_events(evs, pricing).values()) if evs else 0.0
|
||
return tokens, cost
|
||
|
||
|
||
def period_totals(events: List[Dict[str, Any]], gran: str, pricing: Dict[str, Any],
|
||
offset: int = 0, today: Optional[date] = None) -> tuple:
|
||
"""(tokens, cost_usd) for the single period ``offset`` periods from now."""
|
||
start, end = period_bounds(gran, offset, today)
|
||
return _sum_between(events, start, end, pricing)
|
||
|
||
|
||
def period_window(events: List[Dict[str, Any]], gran: str, pricing: Dict[str, Any],
|
||
count: int, offset: int = 0, today: Optional[date] = None) -> List[tuple]:
|
||
"""``count`` consecutive, ZERO-FILLED periods ending at (current + offset),
|
||
ordered oldest→newest → ``[(label, tokens, cost_usd)]``. ``offset`` (≤ 0)
|
||
pages the window into the past for the Dashboard's prev/next navigation."""
|
||
out = []
|
||
for i in range(count - 1, -1, -1):
|
||
start, end = period_bounds(gran, offset - i, today)
|
||
tok, cost = _sum_between(events, start, end, pricing)
|
||
out.append((_period_label(gran, start), tok, cost))
|
||
return out
|
||
|
||
|
||
def period_breakdown(events: List[Dict[str, Any]], gran: str, pricing: Dict[str, Any],
|
||
offset: int = 0, today: Optional[date] = None) -> List[tuple]:
|
||
"""Break the SELECTED period (``offset`` periods from now) into its sub-parts
|
||
→ ``[(label, tokens, cost_usd)]``:
|
||
· week → 7 days Mon→Sun (label ``MM/DD``)
|
||
· month → weeks W1…Wn (7-day chunks from the 1st)
|
||
· year → 12 months (label ``01``…``12``)."""
|
||
from datetime import timedelta
|
||
start, end = period_bounds(gran, offset, today)
|
||
out = []
|
||
if gran == "week":
|
||
for i in range(7):
|
||
d = start + timedelta(days=i)
|
||
tok, cost = _sum_between(events, d, d + timedelta(days=1), pricing)
|
||
out.append((d.strftime("%m/%d"), tok, cost))
|
||
elif gran == "year":
|
||
for m in range(1, 13):
|
||
ms = date(start.year, m, 1)
|
||
me = date(start.year + 1, 1, 1) if m == 12 else date(start.year, m + 1, 1)
|
||
tok, cost = _sum_between(events, ms, me, pricing)
|
||
out.append((f"{m:02d}", tok, cost))
|
||
else: # month → weeks W1..Wn
|
||
ndays = (end - start).days
|
||
wk, day = 1, 1
|
||
while day <= ndays:
|
||
ws = date(start.year, start.month, day)
|
||
we = date(start.year, start.month, day + 7) if day + 7 <= ndays else end
|
||
tok, cost = _sum_between(events, ws, we, pricing)
|
||
out.append((f"W{wk}", tok, cost))
|
||
wk += 1
|
||
day += 7
|
||
return out
|
||
|
||
|
||
def period_range_label(gran: str, offset: int, today: Optional[date] = None) -> str:
|
||
"""Human label for the selected period (shown in the Dashboard header) —
|
||
week → MM/DD – MM/DD, month → YYYY/MM, year → YYYY."""
|
||
from datetime import timedelta
|
||
start, end = period_bounds(gran, offset, today)
|
||
if gran == "week":
|
||
last_day = end - timedelta(days=1)
|
||
return f"{start.strftime('%m/%d')} – {last_day.strftime('%m/%d')}"
|
||
if gran == "year":
|
||
return str(start.year)
|
||
return start.strftime("%Y/%m")
|
||
|
||
|
||
def set_budget(config, amount: float, currency: Optional[str] = None) -> None:
|
||
"""Set (or reset) the spending budget. ``amount`` is read in ``currency``
|
||
(defaults to the current display currency) and converted + stored as USD.
|
||
|
||
Remaining balance is always DERIVED fresh from the usage log — never
|
||
incrementally decremented — so re-entering a budget starts a clean window
|
||
instead of double-subtracting spend the old budget had already accounted
|
||
for. The cutoff is an EVENT-COUNT baseline (how many usage events existed
|
||
at the moment of setting), not a timestamp: events append in chronological
|
||
order and ``budget_set_at`` only has 1-second resolution, so a timestamp
|
||
cutoff could mis-include/exclude an event recorded in that same second —
|
||
the count baseline is exact regardless of timing."""
|
||
from . import model_pricing as mp
|
||
usage = config.data.setdefault("usage", {})
|
||
ccy = (currency or usage.get("currency") or "USD").upper()
|
||
usage["budget_amount_usd"] = mp.convert(float(amount or 0), ccy, "USD", config)
|
||
usage["budget_set_at"] = datetime.now().isoformat(timespec="seconds") # display only
|
||
usage["budget_baseline_count"] = len(load_events()) # the real cutoff
|
||
|
||
|
||
def clear_budget(config) -> None:
|
||
"""Remove the budget entirely (Remaining/Budget box goes back to unset)."""
|
||
usage = config.data.setdefault("usage", {})
|
||
usage.pop("budget_amount_usd", None)
|
||
usage.pop("budget_set_at", None)
|
||
usage.pop("budget_baseline_count", None)
|
||
|
||
|
||
def budget_status(config) -> Optional[Dict[str, Any]]:
|
||
"""``None`` when no budget is configured. Else a dict with ``amount_usd``,
|
||
``spent_usd`` (cost of events recorded AFTER the budget was last set — NOT
|
||
the all-time total, so a reset budget never inherits older spend),
|
||
``remaining_usd``, ``pct_used`` and ``over_85`` (⚠ the Overview/Dashboard
|
||
balance turns red at this point)."""
|
||
usage = (getattr(config, "data", {}) or {}).get("usage") or {}
|
||
amount = usage.get("budget_amount_usd")
|
||
set_at = usage.get("budget_set_at")
|
||
if not amount or not set_at:
|
||
return None
|
||
all_events = load_events()
|
||
baseline = usage.get("budget_baseline_count")
|
||
if baseline is None:
|
||
# backward-compat: a budget set before this field existed — fall back
|
||
# to the timestamp cutoff (best-effort, may double-count a same-second event).
|
||
events = [e for e in all_events if str(e.get("ts", "")) >= str(set_at)]
|
||
else:
|
||
events = all_events[int(baseline):]
|
||
pricing = {**DEFAULT_PRICING, **usage}
|
||
spent = sum(cost_usd_events(events, pricing).values())
|
||
amount = float(amount)
|
||
pct = (spent / amount) if amount else 0.0
|
||
return {
|
||
"amount_usd": amount, "spent_usd": spent, "remaining_usd": amount - spent,
|
||
"pct_used": pct, "over_85": pct >= 0.85, "set_at": set_at,
|
||
}
|
||
|
||
|
||
def format_cost(usd: float, pricing: Dict[str, Any], digits: Optional[int] = None) -> str:
|
||
"""Format a USD amount in the display currency. ``digits`` caps the number
|
||
of decimal places (e.g. ``digits=2`` for the Total cost / Budget cards, so
|
||
USD shows $1.23 not the default up-to-4 $1.2345) — never ADDS decimals to a
|
||
currency that uses fewer (VND stays whole, JPY one place)."""
|
||
p = {**DEFAULT_PRICING, **(pricing or {})}
|
||
cur = p.get("currency", "USD")
|
||
rate = {"USD": 1.0, "VND": float(p["usd_to_vnd"]), "JPY": float(p["usd_to_jpy"])}.get(cur, 1.0)
|
||
symbol, cur_digits = _CURRENCY_FMT.get(cur, ("$", 2))
|
||
if digits is not None:
|
||
cur_digits = min(cur_digits, digits)
|
||
value = usd * rate
|
||
return f"{symbol}{value:,.{cur_digits}f}"
|
||
|
||
|
||
def format_cost_compact(usd: float, pricing: Dict[str, Any]) -> str:
|
||
"""Compact cost format for the Dashboard chart's y-axis/endpoint labels —
|
||
always 2 decimals (not format_cost's up-to-4 for USD) and abbreviated with
|
||
K/M above 1,000/1,000,000, same convention as ``fmt_tokens``. The chart's
|
||
y-axis label box is narrow; the longer full-precision string used to
|
||
overflow it, visually clipping/obscuring the leading currency symbol."""
|
||
p = {**DEFAULT_PRICING, **(pricing or {})}
|
||
cur = p.get("currency", "USD")
|
||
rate = {"USD": 1.0, "VND": float(p["usd_to_vnd"]), "JPY": float(p["usd_to_jpy"])}.get(cur, 1.0)
|
||
symbol, _digits = _CURRENCY_FMT.get(cur, ("$", 2))
|
||
value = usd * rate
|
||
sign = "-" if value < 0 else ""
|
||
value = abs(value)
|
||
if value >= 1_000_000:
|
||
body = f"{value / 1_000_000:,.2f}M"
|
||
elif value >= 1_000:
|
||
body = f"{value / 1_000:,.2f}K"
|
||
else:
|
||
body = f"{value:,.2f}"
|
||
return f"{sign}{symbol}{body}"
|
||
|
||
|
||
_AI_ANALYSIS_HEADERS = {
|
||
"vi": ("Nhận xét thói quen", "Cách viết prompt tiết kiệm hơn", "Hành động giảm token"),
|
||
"en": ("Usage habits", "Writing more efficient prompts", "Actions to cut token usage"),
|
||
"ja": ("利用傾向", "より効率的なプロンプトの書き方", "トークン削減のためのアクション"),
|
||
}
|
||
|
||
|
||
def build_ai_analysis_prompt(summary: Dict[str, Any], language: str = "vi") -> str:
|
||
"""The prompt sent to the model for '✨ AI analyze my usage': aggregated
|
||
numbers only — never raw prompt contents — asking for concrete habits
|
||
feedback and token-saving recommendations, in the CURRENTLY SELECTED
|
||
display language (headers included — not just the model's free-text reply,
|
||
which would otherwise leave the section titles in Vietnamese regardless of
|
||
the app's language setting)."""
|
||
lang_names = {"vi": "Vietnamese", "ja": "Japanese", "en": "English"}
|
||
h1, h2, h3 = _AI_ANALYSIS_HEADERS.get(language, _AI_ANALYSIS_HEADERS["vi"])
|
||
top = "\n".join(f"- {label}: {tok:,} tokens"
|
||
for label, tok in summary.get("top_labels", []))
|
||
by_source = ", ".join(f"{k}={v:,}" for k, v in summary.get("by_source", []))
|
||
return (
|
||
"You are a token-efficiency coach for an AI desktop app (chat tabs + "
|
||
"scheduled agent tasks). Analyze this usage summary and give the user "
|
||
"practical advice, replying in "
|
||
f"{lang_names.get(language, 'Vietnamese')}.\n\n"
|
||
f"Period stats: {summary.get('turns', 0)} turns, "
|
||
f"input={summary.get('in', 0):,} tokens, output={summary.get('out', 0):,}, "
|
||
f"cache={summary.get('cache', 0):,}, "
|
||
f"avg per prompt={summary.get('avg_per_turn', 0):,}.\n"
|
||
f"Top consumers:\n{top or '- (none)'}\n"
|
||
f"By area: {by_source or '(none)'}\n"
|
||
f"Busiest day: {summary.get('busiest_day')} · busiest hour: {summary.get('busiest_hour')}\n\n"
|
||
"Reply with EXACTLY these 3 short sections, in markdown, using THESE "
|
||
f"section headers verbatim (already in {lang_names.get(language, 'Vietnamese')}):\n"
|
||
f"1. **{h1}** — 2-3 bullet points about the usage pattern.\n"
|
||
f"2. **{h2}** — 3 concrete prompt-writing tips "
|
||
"tailored to the numbers above (e.g. long inputs → attach less / summarize "
|
||
"first; many small turns → batch questions).\n"
|
||
f"3. **{h3}** — 2-3 app-level actions (compact history, "
|
||
"smaller model for simple tasks, reuse task outputs instead of re-asking).\n"
|
||
"Keep the whole reply under 250 words."
|
||
)
|
||
|
||
|