metrics: energy and carbon per day, from measured GreenPT factors

Implements the approach Nathan settled on in co-op/support#26: per-token
estimates based on what GreenPT publishes, rather than waiting on the upstream
LiteLLM `impact` passthrough.

- New daily columns: out_tokens, energy_wh, co2e_g.
- Tokens come from OUR OWN spend logs (completion_tokens per model per day), so
  member-visible impact does not depend on any client persisting an `impact`
  object. GreenPT's metric counts OUTPUT tokens only — that is the unit — so
  input tokens are excluded deliberately.
- Factors live in one table with a source per row. The greenpt/* rows are
  MEASURED: fitted from live calls to their API across prompt sizes
  (energy = overhead + marginal x output tokens, R2 >= 0.999; carbon from the
  grid intensity their own emissions imply — ug/mWh and g/kWh are the same
  ratio). Everything else falls back to a documented order-of-magnitude proxy,
  so a day's total is never silently missing models.
- Migration: CREATE IF NOT EXISTS never adds columns, so both the script and
  the app ALTER TABLE on connect.

Refining a factor is a data edit in IMPACT_FACTORS, not a code change.
This commit is contained in:
inference-bot committed 2026-10-28 04:32:05 +00:00
1 parent 5a9b0d2b0d
commit af610fe25f
2 files changed
+153 -7

No files matched your search

+20 -3
View File
@@ -622,9 +622,19 @@ def get_db() -> sqlite3.Connection:
"matrix_space_members INTEGER, "
"spend_usd REAL, "
"requests INTEGER, "
"out_tokens INTEGER, "
"energy_wh REAL, "
"co2e_g REAL, "
"recorded_at TEXT"
")"
)
# CREATE IF NOT EXISTS never adds columns to an existing table, so migrate:
# impact columns arrived after the table did (see co-op/support#26).
for _col, _decl in (("out_tokens", "INTEGER"), ("energy_wh", "REAL"), ("co2e_g", "REAL")):
try:
conn.execute(f"ALTER TABLE metrics_daily ADD COLUMN {_col} {_decl}")
except sqlite3.OperationalError:
pass # already present
# Member-managed API keys (created via the broker). Each row is one API key
# under the member's team, tracked so we can list/revoke by name without
# exposing the chat key. `sk_token` is the full sk- key (revealed once).
@@ -3059,15 +3069,19 @@ def metrics_series(days: int = 90) -> list[dict]:
One query, two callers: the admin panel (via /admin/metrics/series) and the
member dashboard (via /broker/metrics). Columns are cumulative as of that
day except spend_usd/requests, which are that day only; NULL means the
source could not be read that day.
day except spend_usd/requests/out_tokens/energy_wh/co2e_g, which are that
day only; NULL means the source could not be read that day.
energy_wh and co2e_g are computed in metrics_snapshot.py from the day's
output tokens against a factor table: measured for GreenPT models (their API
returns an `impact` object), estimated for the rest. See co-op/support#26.
"""
limit = max(1, min(int(days or 90), 730))
conn = get_db()
try:
rows = conn.execute(
"SELECT day, members_active, members_total, members_new, newsletter_subs, "
"matrix_space_members, spend_usd, requests, recorded_at "
"matrix_space_members, spend_usd, requests, out_tokens, energy_wh, co2e_g, recorded_at "
"FROM metrics_daily ORDER BY day DESC LIMIT ?",
(limit,),
).fetchall()
@@ -3082,6 +3096,9 @@ def metrics_series(days: int = 90) -> list[dict]:
"matrix_space_members",
"spend_usd",
"requests",
"out_tokens",
"energy_wh",
"co2e_g",
"recorded_at",
]
return [dict(zip(cols, row)) for row in reversed(rows)]