8000 feat: activity history, costs and budgets by adeelahmad · Pull Request #114 · adeelahmad/lense · GitHub
[go: up one dir, main page]

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
10BC8
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@ The backend (`fastapi_backend`) and the frontend (`nextjs-frontend`) are version

## Unreleased

- **Budgets for routines, pipelines, workflows and namespaces.** An admin caps what a resource may cost, in USD,
tokens or both, per run, day, week or month. Before a routine runs or a job starts, Lens estimates the run from past
ones; one that would go over is held until someone picks run or skip (or is skipped, or the decision model weighs
it when asked to). Budgets near or over their cap are flagged once per period. Off unless set. See docs/budgets.md.
- **Local models cost nothing unless you price them.** A model's price is by tokens (USD per million), by time (USD
per hour, for a model on your own machine) or off, the default for a model without one.

- **Every resource has an activity history, with what it cost.** Lens now keeps a ledger of every request that
changes something, every call it makes out (models, embeddings, the decision model, text to speech, webhooks, web
tools) and every job and routine run, each counted for the resources it touched: a model call in a job a routine
queued counts for the job, its recording, namespace, pipeline, workflow and the routine. Tokens and estimated cost
come from the prices in Settings. `GET /api/v1/activity?resource=routine:3` gives a resource's history (with its
audit log entries), `/activity/totals` what it cost this day, week or month, `/activity/top` what cost most. On by
default; `activity.keep_days` (365) keeps it in bounds. See docs/activity.md.
- **Safer upgrades of existing installs.** Data upgrades are now named steps that run once per database, under a lock
so only one process runs them while the others wait, with a record of when each ran and why one failed.
`lens migrations` lists them. A database with data is backed up into `<data_dir>/backups` before it is upgraded
Expand Down
39 changes: 39 additions & 0 deletions DEVLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,45 @@

Plans and progress for work in flight. Newest first.

## 2026-10-04 · Activity history and budgets

Goal (Adeel): every change and every call coming in or going out is tracked, with its cost, down to the resource, so
each resource has an activity history. Pipelines, workflows and routines can be capped at a budget, and before a run
(or something larger) the assistant looks at where the budget stands and whether the run should go ahead.

Model:

- One ledger, `activity`: a row per call in (API requests that change something), call out (model, embeddings,
decision model, webhooks, web tools) and run (a job, a routine run). Each row names every resource it touched
(`routine:3`, `routine_run:12`, `job:40`, `recording:7`, `pipeline:2`, `workflow:4`, `chat:9`, `space:1`,
`account:1`), with tokens, estimated cost (the prices in Settings), duration and outcome; never prompt or reply text.
Who a call is for comes from the work it runs in (a scope), so a model call inside a job counts for the job, its
recording, pipeline, namespace and the routine that queued it.
- A resource's history is its ledger rows plus its audit log entries. On by default, nothing to set up; kept
`activity.keep_days` (365). Reads (GET) aren't logged unless `activity.reads` is on.
- Budgets are off unless set: a budget on a routine, pipeline, workflow or namespace caps cost (USD) and/or tokens per
run, day, week or month. Before a routine run or a job, Lens estimates the run from past runs and checks what is
left. Over budget, it asks you (the default: the run waits for your pick), skips, or lets the assistant weigh it (the
decision model; unsure means ask). Doing nothing changes nothing: a waiting run stays waiting.

Todo:

< 8 /td> 10BC8 - [x] Activity ledger: scopes, model/embedding/decision/webhook/web-tool calls, changing API requests, job and routine
run totals, per-resource history and cost summary API, retention sweep
- [ ] Activity in the web app: a history panel on routines, pipelines, workflows and recordings; Settings → Activity
and costs with totals by resource
- [ ] Cost in every view (Adeel): every resource, entity and view shows what it cost and its budget; a figure that
isn't exact (no price for a model, tokens the server didn't report, a run's forecast) is marked as an estimate
(`≈`, with why on hover). The API says `exact` / `estimate` per figure
- [x] Budgets: set per routine, pipeline, workflow or namespace; spent and estimate; checked before routine runs and
jobs; over budget waits for your pick (run once, skip), or the assistant decides
- [ ] Budgets in the web app: set a budget, see where it stands, approve or skip a waiting run
- [x] Periodic check: warn at 80% and 100% of a budget, once per period (on the budget and in its history)
- [x] Local model costs (Adeel): price per model by tokens, by time (per hour) or off; off by default
- [ ] Budget warnings through notifications, and the assistant answering where budgets stand

Refine later: compute time as a cost (CPU seconds × a rate for local models), per-person budgets, forecasting from
schedules (a routine's next runs this period), budget alerts in the weekly digest.
## 2026-10-04 · Local decision models: Laya on MLX

Goal (Adeel): routine decisions can run on the machine itself with the Laya typed decision models on MLX
Expand Down
82 changes: 82 additions & 0 deletions docs/activity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Activity and costs

Lens keeps a ledger of what happens to each resource and what it cost: every request that changes something, every
call it makes out (a language model, embeddings, the decision model, text to speech, a webhook, a web tool), and every
job or routine run that ends. It is on by default and needs nothing set up.

## What a row holds

Each row names every resource it counts for, as `table:id`:

| Resource | Example |
| --- | --- |
| A routine and one of its runs | `routine:3`, `routine_run:12` |
| A job, its recording, namespace and pipeline | `job:40`, `recording:7`, `space:1`, `pipeline:2` |
| A workflow | `workflow:4` |
| A chat | `chat:9` |
| The person who asked | `account:1` |

A model call inside a workflow step of a job that a routine queued counts for the workflow, the job, the recording,
its namespace, its pipeline and the routine. A request counts for the resources in its path (`PATCH
/api/v1/routines/3` is `routine:3`), for what a `POST` made (from the `id` in its reply), and for who sent it.

Besides the resources, a row has its kind (`in`, `out` or `run`), what it was (`POST /api/v1/routines/{rid}`,
`model.chat`, `embeddings`, `decision`, `notify.webhook`, `tool.http`, `job.succeeded`, `routine.done`), the model,
tokens in and out, the estimated cost in USD, how long it took, and how it ended (an error's type or HTTP status,
never its message). It never holds prompt, reply, transcript or file content.

## Costs

Each model is costed the way you set it under Settings › Telemetry › prices (`telemetry.prices`), or, for the
assistant's model, Settings › AI assistant:

| Unit | Setting | For |
| --- | --- | --- |
| Tokens | `{"input": 3.0, "output": 15.0}` (USD per million tokens) | cloud models that bill by tokens |
| Time | `{"unit": "time", "per_hour": 0.6}` (USD per hour a call takes) | a local model on your own machine |
| Off | `{"unit": "off"}`, or leave the model out | the default: no money counted, only tokens and time |

So local models aren't costed unless you give them a price. `decisions.price_per_call` gives the decision model's
price per decision.

A figure that should have a cost but doesn't (a model priced by tokens whose server didn't report how many it used)
marks the row `unpriced`, and every total that includes it says `estimate: true`: the cost shown is a floor, and
the web app shows it with ≈.

A run row (a job or a routine run ending) repeats what its calls cost in all, and the job or run keeps it too
(`cost_usd`, `tokens`). Totals don't count run rows twice.

## Reading it

A resource's history merges its ledger rows with its audit log entries (kind `change`), newest first:

```http
GET /api/v1/activity?resource=routine:3&limit=100
GET /api/v1/activity?resource=routine:3&kind=out&before=2026-10-04T09:00:00.000+00:00
```

What it cost this day, week (from Monday) or month (UTC), or all time:

```http
GET /api/v1/activity/totals?resource=pipeline:2&period=month
GET /api/v1/activity/totals?period=day # everything (admins)
GET /api/v1/activity/top?table=routine&period=week # what cost most (admins)
```

Admins see every resource. Everyone else sees their own account's activity, their own chats', and the recordings and
namespaces they can read.

## Settings

| Setting | Default | |
| --- | --- | --- |
| `activity.enabled` | `true` | Off stops new rows (the audit log carries on). |
| `activity.reads` | `false` | Log API reads (GET) too. The web app reads often, so this grows the ledger fast. |
| `activity.keep_days` | `365` | Rows older than this are dropped, hourly, by the routines scheduler. |

Sign-in requests (`/api/v1/auth/`, `/api/v1/passkeys/`) aren't logged here; the audit log records sign-ins.

## Refine later

Compute time as a cost for local models (CPU seconds at a rate), per-person totals in the web app, and calls the
ledger doesn't see yet (Fedora sync, Matterbridge messages).
53 changes: 53 additions & 0 deletions docs/budgets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Budgets

A budget caps what a routine, pipeline, workflow or namespace may cost. Budgets are off unless an admin sets one.
What has been spent comes from the [activity ledger](activity.md), so a budget counts everything done for that
resource: a namespace's budget counts every job in it, and a routine's counts its runs and the jobs it queued.

## Setting one

```http
PUT /api/v1/budgets?resource=routine:3
{"usd": 5, "period": "month", "on_over": "ask"}
```

| Field | | |
| --- | --- | --- |
| `usd`, `tokens` | at least one | the cap in USD, tokens (in and out), or both |
| `period` | `month` | `run` (each run on its own), `day`, `week` (from Monday) or `month` (UTC) |
| `on_over` | `ask` | what happens to a run that would go over: see below |
| `warn_at` | `0.8` | the share spent at which the budget is `near` and gets a warning |

`GET /api/v1/budgets` lists every budget with where it stands, `GET /api/v1/budgets/status?resource=…` gives one
(also for a resource without a budget, with its estimated next run), and `DELETE /api/v1/budgets?resource=…`
removes one.

## Before a run

Before a routine runs and before a job's first step, Lens looks at the budgets of everything the run counts for: the
routine, the pipelines and workflows its actions name and its namespaces; a job's pipeline, namespace and the routine
that queued it. It estimates the next run from the average of that resource's runs in the last 30 days (always an
estimate, shown with ≈). A run goes ahead unless a budget is used up, or would be once the estimated run is done
(for a `run` budget, unless the estimate alone passes the cap). Then `on_over` decides:

- `ask` (default): the run is held. A routine gets a run with status `held`, a job the status `held`, each with
`hold.why` saying where the budget stands. Someone picks: run it now, once, whatever the budget says
(`POST /api/v1/routines/runs/{run}/decide {"run": true}`, `POST /api/v1/jobs/{jid}/release {"run": true}`), or skip
it (`{"run": false}`). Doing nothing changes nothing: a held run stays held, and a routine that comes due again adds
to the one held run (`hold.missed`) rather than piling up.
- `skip`: the run is skipped, and says why.
- `assistant`: the decision model (Settings › AI assistant, decisions) weighs it, with where the budget stands and the
estimate. It runs only when the model is sure (`decisions.act_above`); otherwise it's held as with `ask`.

Asking a routine to run from the app or the API when it's over budget is refused with 409 and the reason; send
`{"over_budget": true}` to run it anyway.

## Warnings

Every five minutes the scheduler looks at every budget. One that crosses `warn_at` or its cap gets an alert, once per
period: on the budget (`alert`) and as a `budget.near` or `budget.over` row in its activity history.

## Refine later

Forecasting from a routine's schedule (its runs left this period), per-person budgets, budget warnings sent through
notifications and the weekly digest, and the assistant answering "where do my budgets stand?" in chat.
4 changes: 4 additions & 0 deletions fastapi_backend/app/api/v1/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
from fastapi import APIRouter

from app.api.v1.routes import (
activity,
admin,
auth,
batches,
budgets,
chats,
collections,
comments,
Expand Down Expand Up @@ -64,6 +66,8 @@
for module in (
users,
admin,
activity,
budgets,
fedora,
setup,
namespaces,
Expand Down
109 changes: 109 additions & 0 deletions fastapi_backend/app/api/v1/routes/activity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
"""The activity ledger (docs/activity.md): a resource's history (its calls in and out, runs and changes) and what it
cost. Admins see every resource's; others see their own account's, their chats', and the recordings and namespaces they
can read."""

from __future__ import annotations

import re
from typing import Literal

from fastapi import APIRouter, HTTPException, Query

from app.api.deps import Acl, AdminReader, CurrentUser, Db
from app.domain import activity
from app.schemas.activity import ActivityCosts, ActivityEntry, ActivityTop, ActivityTotals

router = APIRouter(tags=["activity"])
Period = Literal["day", "week", "month", "all"]
RESOURCE = re.compile(r"^([a-z_]{1,40}):([A-Za-z0-9_.-]{1,80})$")


def _may_see(db: Db, user: CurrentUser, acl: Acl, resource: str) -> None:
m = RESOURCE.match(resource)
if not m:
raise HTTPException(422, "resource is table:id, like routine:3 or recording:12")
if user.admin:
return
table, key = m.groups()
if table == "account" and key == str(user.id):
return
if key.isdigit():
if table == "recording":
acl.recording(int(key))
return
if table == "space":
acl.need(int(key))
return
if table == "chat" and db.one("SELECT id FROM $r WHERE account = $a", r=activity.R("chat", int(key)), a=user.id):
return
raise HTTPException(403, "only admins see this resource's activity")


@router.get("/activity")
def resource_history(
user: CurrentUser,
acl: Acl,
db: Db,
resource: str = Query(description="table:id, like routine:3, pipeline:2, workflow:4, recording:12, account:1"),
kind: Literal["in", "out", "run", "change"] | None = None,
before: str | None = Query(None, description="only entries before this time (the last `at` of the page before)"),
limit: int = Query(100, ge=1, le=1000),
) -> list[ActivityEntry]:
"""A resource's history, newest first: API requests that changed it, calls made for it (models, embeddings, the
decision model, webhooks, web tools) with tokens and cost, runs that ended, and its audit log entries."""
_may_see(db, user, acl, resource)
return activity.history(db, resource, limit, before, kind)


@router.get("/activity/totals")
def resource_totals(
user: CurrentUser,
acl: Acl,
db: Db,
resource: str | None = Query(None, description="table:id; none for everything (admins)"),
period: Period = "month",
) -> ActivityTotals:
"""What a resource's calls cost this day, week (from Monday), month (UTC) or all time: calls, tokens, USD, time."""
if resource is None:
if not user.admin:
raise HTTPException(403, "only admins see the totals for everything")
else:
_may_see(db, user, acl, resource)
since = activity._since(period)
return ActivityTotals(resource=resource, period=period, since=since, **activity.totals(db, resource, since))


@router.get("/activity/costs")
def resource_costs(
user: CurrentUser,
acl: Acl,
db: Db,
resource: list[str] = Query(description="table:id, repeated (up to 500): every row of a list at once"),
period: Period = "month",
) -> ActivityCosts:
"""What each of many resources cost this period, for lists; ones you can't see are left out. `estimate` says a
figure is a floor (some calls had no price or token counts)."""
seen = []
for r in resource[:500]:
try:
_may_see(db, user, acl, r)
except HTTPException:
continue
seen.append(r)
since = activity._since(period)
return ActivityCosts(period=period, since=since, costs=activity.costs(db, seen, since))


@router.get("/activity/top")
def top_resources(
user: AdminReader,
db: Db,
table: str | None = Query(
None, pattern=r"^[a-z_]{1,40}$", description="only this kind of resource: routine, pipeline, workflow, space"
),
period: Period = "month",
limit: int = Query(20, ge=1, le=200),
) -> ActivityTop:
"""The resources that cost most this period (admins)."""
since = activity._since(period)
return ActivityTop(period=period, since=since, resources=activity.top(db, table, since, limit))
46 changes: 46 additions & 0 deletions fastapi_backend/app/api/v1/routes/budgets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""Budgets (admins set them; docs/budgets.md): caps on what a routine, pipeline, workflow or namespace may cost, and
where each stands."""

from __future__ import annotations

from fastapi import APIRouter, HTTPException, Query

from app.api.deps import AdminReader, AdminWriter, Db, domain_errors
from app.domain import auth, budgets
from app.schemas.budgets import BudgetSet, BudgetStatus
from app.schemas.common import Ok

router = APIRouter(tags=["budgets"])
RESOURCE = Query(description="routine:<id>, pipeline:<id>, workflow:<id> or space:<id>")


@router.get("/budgets")
def list_budgets(user: AdminReader, db: Db) -> list[BudgetStatus]:
"""Every budget, with where it stands now."""
return [budgets.status(db, b["resource"], b) for b in budgets.all_budgets(db)]


@router.get("/budgets/status")
def budget_status(user: AdminReader, db: Db, resource: str = RESOURCE) -> BudgetStatus:
"""Where a resource's budget stands, and what its next run will likely cost (also without a budget)."""
with domain_errors():
budgets.check_ref(resource)
return budgets.status(db, resource)


@router.put("/budgets")
def set_budget(body: BudgetSet, user: AdminWriter, db: Db, resource: str = RESOURCE) -> BudgetStatus:
with domain_errors():
budgets.put(db, resource, by=user.email, **body.model_dump())
auth.audit(db, user.as_audit(), "budget.set", resource, body.model_dump(exclude_none=True))
return budgets.status(db, resource)


@router.delete("/budgets")
def remove_budget(user: AdminWriter, db: Db, resource: str = RESOURCE) -> Ok:
with domain_errors():
if not budgets.get(db, budgets.check_ref(resource)):
raise HTTPException(404, "this resource has no budget")
budgets.remove(db, resource)
auth.audit(db, user.as_audit(), "budget.remove", resource)
return Ok()
Loading
Loading
0