Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,19 @@ skill loads, filtered by machine or project. Missing usage is identified rather
than guessed. These are usage metrics, not a billing dashboard or a measurement
of task correctness; the chart below uses sample history.

The total and token breakdown show exact counts: uncached input + cache reads +
cache writes + output (including reasoning). Usage is counted across all recorded
providers and models in the selected projects and machines, not just the currently
selected model. Anthropic, OpenAI, Codex, DeepSeek, and compatible/local endpoints
use their reported usage; missing counts are never estimated from text.

Cache writes are input tokens the provider reports saving for reuse, not files
written by Ava. Anthropic reports cache creation explicitly; many other endpoints
report cache reads but no separate write count. An unreported cache-write count is
shown as **—**, labeled **Not reported by these providers**, rather than zero.
When only some requests report cache writes, the breakdown shows how many did.
The total includes known counts only; it is not a billing estimate.

![Seven-day sample usage with token trends, tool activity, and observed skill loads](assets/demo/session-information.png)

## Git and terminals
Expand Down
21 changes: 16 additions & 5 deletions src/ava/app/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ def merge_intervals(intervals: list) -> list[list[int]]:


def totals(days: list[dict]) -> dict:
names = (*TOKEN_FIELDS, "responses", "missing_usage", "tools", "tool_errors", "skills", "runs", "run_ms", "active_ms")
names = (*TOKEN_FIELDS, "cache_write_reports", "responses", "missing_usage", "tools", "tool_errors", "skills", "runs", "run_ms", "active_ms")
result = {name: sum(day.get(name, 0) for day in days) for name in names}
result["tokens"] = sum(result[key] for key in TOKEN_FIELDS if key != "reasoning")
return result
Expand All @@ -45,7 +45,8 @@ def __init__(self, path: Path) -> None:
os.chmod(path, 0o600)
self.db.row_factory = sqlite3.Row
try:
if self.db.execute("PRAGMA user_version").fetchone()[0] not in (0, 1):
version = self.db.execute("PRAGMA user_version").fetchone()[0]
if version not in (0, 1, 2):
raise sqlite3.DatabaseError("Analytics cache uses a different aggregation version.")
self.db.executescript("""
PRAGMA journal_mode=WAL;
Expand Down Expand Up @@ -78,8 +79,13 @@ def __init__(self, path: Path) -> None:
CREATE TABLE IF NOT EXISTS days (
zone TEXT, project TEXT, day TEXT, start INTEGER, end INTEGER, body TEXT,
PRIMARY KEY(zone,project,day));
PRAGMA user_version=1;
""")
with self.db:
if version < 2:
# Facts retain the original disjoint token categories. Only
# derived days need rebuilding for inclusive output/reporting.
self.db.execute("DELETE FROM days")
self.db.execute("PRAGMA user_version=2")
except sqlite3.DatabaseError:
self.db.close()
raise
Expand Down Expand Up @@ -222,10 +228,15 @@ def _day(self, day: date, zone: ZoneInfo, project: str) -> dict:
end = int(datetime.combine(day + timedelta(days=1), datetime.min.time(), zone).timestamp() * 1000)
where = "logs.visible=1 AND logs.error=''" + (" AND logs.project=?" if project else "")
params = (project,) if project else ()
usage = self.db.execute(f"""SELECT COUNT(*) responses, SUM(input IS NULL OR output IS NULL) missing_usage,
usage = self.db.execute(f"""SELECT COUNT(*) responses, COUNT(cache_write) cache_write_reports,
SUM(input IS NULL OR output IS NULL) missing_usage,
{','.join('SUM('+key+') '+key for key in TOKEN_FIELDS)} FROM attempts JOIN logs USING(path)
WHERE {where} AND at>=? AND at<?""", (*params, start, end)).fetchone()
body: dict[str, Any] = {key: usage[key] or 0 for key in usage.keys()}
# OpenAI-compatible and Codex logs split reasoning from output; Anthropic
# leaves it included in output and does not emit a separate reasoning count.
# Reports use inclusive output so the four displayed categories add up.
body["output"] += body["reasoning"]
tools = self.db.execute(f"""SELECT name,COUNT(*) count,SUM(failed) errors,SUM(elapsed) elapsed_ms FROM calls JOIN logs USING(path)
WHERE {where} AND finished=1 AND at>=? AND at<? GROUP BY name""", (*params, start, end)).fetchall()
skills = self.db.execute(f"""SELECT name,COUNT(*) count FROM skills JOIN logs USING(path)
Expand Down Expand Up @@ -262,7 +273,7 @@ def query(self, count: int = 7, timezone: str = "UTC", project: str = "", *, now
day["run_ms"] += sum(b-a for a, b in live)
condition = "visible=1" + (" AND project=?" if project else "")
rows = self.db.execute(f"SELECT error,size,offset FROM logs WHERE {condition}", (project,) if project else ()).fetchall()
return {"days": days, "totals": totals(days), "timezone": timezone, "as_of": now.isoformat(),
return {"token_accounting_version": 2, "days": days, "totals": totals(days), "timezone": timezone, "as_of": now.isoformat(),
"indexing": any(r["size"] == -1 for r in rows) or any(path not in self.checked for path in self.sources),
"sessions": len(rows), "unavailable": sum(bool(r["error"]) for r in rows),
"incomplete": sum(r["size"] > r["offset"] for r in rows), "active_sessions": len(active)}
10 changes: 9 additions & 1 deletion src/ava/app/desktop/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,12 +184,20 @@ def _assemble(self) -> None:
notices.append(machine.name + ": indexing history; totals are still updating")
if report["unavailable"] or report["incomplete"] or report.get("error"):
notices.append(machine.name + ": some history is unavailable or incomplete")
legacy_tokens = report.get("token_accounting_version", 1) < 2
if legacy_tokens:
notices.append("Update the Ava backend on " + machine.name + " for cache-write reporting availability")
for source in report["days"][-self._days:]:
if not first <= source["date"] <= last.isoformat():
continue
target = days.setdefault(source["date"], {"date": source["date"], "intervals": [], "tool_counts": [], "skill_counts": []})
for name in (*TOKEN_FIELDS, "tokens", "responses", "missing_usage", "tools", "tool_errors", "skills", "runs", "run_ms"):
target[name] = target.get(name, 0) + source[name]
value = source[name]
if legacy_tokens and name in ("output", "tokens"):
value += source["reasoning"]
target[name] = target.get(name, 0) + value
# Older backends cannot distinguish unreported writes from zero.
target["cache_write_reports"] = target.get("cache_write_reports", 0) + source.get("cache_write_reports", 0)
for name in ("intervals", "tool_counts", "skill_counts"):
target[name].extend(source[name])
series = sorted(days.values(), key=lambda day: day["date"])
Expand Down
15 changes: 8 additions & 7 deletions src/ava/app/desktop/qml/AnalyticsPane.qml
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ Pane {
contentItem: ColumnLayout {
spacing: Theme.spaceSm
Label { text: card.title; color: Theme.secondaryText; font.pixelSize: Theme.caption }
Label { text: card.value; color: Theme.text; font.pixelSize: 28; font.weight: Font.DemiBold }
Label { Layout.fillWidth: true; text: card.value; color: Theme.text; font.pixelSize: 28; font.weight: Font.DemiBold; fontSizeMode: Text.HorizontalFit; minimumPixelSize: Theme.body }
Label {
Layout.fillWidth: true
text: pane.report.reports ? card.detail : pane.analytics.loading ? "Loading statistics…" : "No available statistics"
Expand Down Expand Up @@ -253,7 +253,7 @@ Pane {
uniformCellWidths: true
columnSpacing: Theme.spaceMd
rowSpacing: Theme.spaceMd
Card { objectName: "analyticsTokens"; Layout.fillWidth: true; Layout.fillHeight: true; title: "Total tokens"; value: pane.report.reports ? pane.compact(pane.report.totals.tokens) : "—"; detail: pane.number(pane.report.totals.responses) + " model requests" }
Card { objectName: "analyticsTokens"; Layout.fillWidth: true; Layout.fillHeight: true; title: "Total tokens"; value: pane.report.reports ? pane.number(pane.report.totals.tokens) : "—"; detail: pane.number(pane.report.totals.responses) + " model requests" }
Card { objectName: "analyticsTime"; Layout.fillWidth: true; Layout.fillHeight: true; title: "Active time"; value: pane.report.reports ? pane.duration(pane.report.totals.active_ms) : "—"; detail: "Overlapping sessions counted once" }
Card { Layout.fillWidth: true; Layout.fillHeight: true; title: "Tool calls"; value: pane.report.reports ? pane.compact(pane.report.totals.tools) : "—"; detail: pane.number(pane.report.totals.tool_errors) + " returned errors" }
Card { Layout.fillWidth: true; Layout.fillHeight: true; title: "Skills loaded"; value: pane.report.reports ? pane.number(pane.report.totals.skills) : "—"; detail: "Recorded instruction loads" }
Expand Down Expand Up @@ -409,7 +409,7 @@ Pane {
contentItem: ColumnLayout {
spacing: Theme.spaceLg
Label { text: "Token breakdown"; font.pixelSize: Theme.sectionTitle; font.weight: Font.DemiBold }
Label { text: "How reported tokens are used"; color: Theme.secondaryText; font.pixelSize: Theme.caption }
Label { text: "Exact reported counts · sum to total tokens"; color: Theme.secondaryText; font.pixelSize: Theme.caption; Layout.fillWidth: true; wrapMode: Text.WordWrap }
Row {
id: tokenStack
Layout.fillWidth: true
Expand All @@ -432,19 +432,20 @@ Pane {
id: token
required property var modelData
required property int index
readonly property bool unreported: modelData.key === "cache_write" && pane.report.totals.responses > 0 && !pane.report.totals.cache_write_reports && !pane.report.totals.cache_write
Layout.fillWidth: true
spacing: Theme.spaceXs
RowLayout {
Layout.fillWidth: true
spacing: Theme.spaceSm
Rectangle { implicitWidth: 8; implicitHeight: 8; radius: 2; color: pane.colors[token.index] }
Label { Layout.fillWidth: true; text: token.modelData.name; font.pixelSize: Theme.body }
Label { text: pane.report.reports ? pane.compact(pane.report.totals[token.modelData.key]) : "—"; font.pixelSize: Theme.body; font.weight: Font.DemiBold }
Label { text: pane.report.reports ? pane.percentage(pane.report.totals[token.modelData.key], pane.report.totals.tokens) : "—"; Layout.preferredWidth: 36; horizontalAlignment: Text.AlignRight; font.pixelSize: Theme.caption; color: Theme.secondaryText }
Label { objectName: "analyticsTokenValue_" + token.modelData.key; text: pane.report.reports && !token.unreported ? pane.number(pane.report.totals[token.modelData.key]) : "—"; font.pixelSize: Theme.body; font.weight: Font.DemiBold }
Label { text: pane.report.reports && !token.unreported ? pane.percentage(pane.report.totals[token.modelData.key], pane.report.totals.tokens) : "—"; Layout.preferredWidth: 36; horizontalAlignment: Text.AlignRight; font.pixelSize: Theme.caption; color: Theme.secondaryText }
}
Label { text: token.modelData.detail; leftPadding: 16; color: Theme.secondaryText; font.pixelSize: Theme.captionSmall }
Label { text: token.unreported ? "Not reported by these providers" : token.modelData.key === "cache_write" && pane.report.totals.cache_write_reports > 0 ? "Reported on " + pane.number(pane.report.totals.cache_write_reports) + " of " + pane.number(pane.report.totals.responses) + " requests" : token.modelData.detail; Layout.fillWidth: true; wrapMode: Text.WordWrap; leftPadding: 16; color: Theme.secondaryText; font.pixelSize: Theme.captionSmall }
HoverHandler { id: tokenHover }
NativeToolTip { visible: tokenHover.hovered && pane.report.reports > 0; text: pane.number(pane.report.totals[token.modelData.key]) + " " + token.modelData.name.toLowerCase() + " tokens" }
NativeToolTip { visible: tokenHover.hovered && pane.report.reports > 0 && !token.unreported; text: pane.number(pane.report.totals[token.modelData.key]) + " " + token.modelData.name.toLowerCase() + " tokens" }
}
}
Item { Layout.fillHeight: true }
Expand Down
2 changes: 1 addition & 1 deletion src/ava/app/desktop/quick_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ class QuickChatShortcut(QObject):

def __init__(self, parent: QObject | None = None) -> None:
super().__init__(parent)
self._carbon = None
self._carbon: ctypes.CDLL | None = None
self._handler = ctypes.c_void_p()
self._hotkey = ctypes.c_void_p()
self.error = ""
Expand Down
Loading
Loading