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
8 changes: 6 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -272,14 +272,18 @@ RATE_LIMIT_AI_ENABLED=1
# are updated from the usage ledger, and label cardinality is capped.
METRICS_ENABLED=1
METRICS_TOKEN=
# Optional SMTP for invitation emails. When unset, invitations are delivered as
# in-app notifications only (never crashes a request on mail failure).
# Optional SMTP for invitation and budget-alert emails. When unset, invitations
# and alerts are delivered as in-app notifications only (never crashes a request
# on mail failure). BILLING_ALERT_EMAIL is the recipient for over-budget alerts;
# in-app budget alerts are always created regardless of this setting.
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
MAIL_DEFAULT_SENDER=
MAIL_USE_TLS=1
# Recipient for over-budget email alerts (best-effort; empty disables email).
BILLING_ALERT_EMAIL=

# Stellar / Soroban integration (Phase 8).
# Default network used by the Stellar service: mainnet | testnet | futurenet | custom.
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,15 +98,19 @@ from a documented price table, not provider invoices.
| `USAGE_RETENTION_DAYS` | `365` | Minimum age before `flask usage purge` may delete ledger rows. |
| `BUDGET_ENFORCEMENT_ENABLED` | `1` | Refuses LLM calls that would exceed a monthly token/cost budget (fail closed). |
| `BUDGET_DEFAULT_WARN_THRESHOLD` | `0.8` | Default warning fraction for new budgets. |
| `BILLING_ALERT_EMAIL` | empty | Recipient for best-effort over-budget alert email; empty disables email. |
| `BILLING_IMPORT_MAX_BYTES` | `1048576` | Maximum provider billing CSV upload size. |
| `BILLING_IMPORT_MAX_ROWS` | `5000` | Maximum billing CSV rows. |
| `USAGE_EXPORT_CSV_MAX_ROWS` | `5000` | Hard row cap for CSV exports; clipped exports advertise truncation. |
| `CURRENCY_CODE` | `USD` | Currency code shown on every cost figure. |
| `CURRENCY_SYMBOL` | `$` | Display symbol paired with the currency code. |
| `CURRENCY_DECIMAL_PLACES` | `4` | Display precision for estimates (stored costs use six decimals). |
| `LLM_MODEL` | empty | Pre-flight estimate model; when set, the price table is used. |
| `LLM_PRICE_TABLE` | empty | Optional JSON provider/model price overlay; invalid values log and fall back safely. |

See [Observability](docs/observability.md) for the ledger, metrics, dashboards,
and export contract, and [Billing](docs/billing.md) for the price table,
budgets, and reconciliation.
time series, and export contract, and [Billing](docs/billing.md) for the price
table, quotas, budgets, alerts, invoices, and reconciliation.

## Documentation

Expand Down
118 changes: 115 additions & 3 deletions app/admin/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,25 @@

from __future__ import annotations

from datetime import UTC, datetime, timedelta
from functools import wraps

from flask import Response, jsonify, request
from flask import Response, jsonify, render_template, request
from flask_login import current_user, login_required

from app.admin import bp
from app.models import AuditLog, User
from app.services import usage_dashboards
from app.models import AuditLog, UsageRecord, User, Workspace
from app.services import usage_dashboards, usage_ledger
from app.services.currency import get_currency_config

#: Hard cap on page size so an admin cannot force an unbounded scan.
MAX_PER_PAGE = 200

#: Bounds for the detailed usage report table (#480).
REPORT_DEFAULT_PER_PAGE = 50
REPORT_MAX_PER_PAGE = 100
REPORT_SCOPES = ("all", "user", "workspace")


def admin_required(view):
"""Reject non-admin callers with a 403 JSON error."""
Expand Down Expand Up @@ -97,6 +104,111 @@
return jsonify(usage_dashboards.admin_dashboard(days=days))


@bp.route("/usage/report")
@login_required
@admin_required
def usage_report():
"""Render the admin usage report page with filters and a bounded table.

Query parameters:
``days`` date window, 1-365 days (default 30).
``scope`` ``all``, ``user`` or ``workspace``.
``user_id`` required when ``scope=user``.
``workspace_id`` required when ``scope=workspace``.
``source`` / ``provider`` / ``model`` optional exact filters.
``page`` / ``per_page`` bounded pagination (``per_page`` capped at
:data:`REPORT_MAX_PER_PAGE`).

All figures come from stored :class:`~app.models.UsageRecord` rows. The
page never reads prompts or completions.
"""
try:
days = usage_dashboards.normalize_days(request.args.get("days"))
except ValueError as exc:
return jsonify({"error": str(exc), "kind": "invalid"}), 400

scope = (request.args.get("scope") or "all").strip().lower()
if scope not in REPORT_SCOPES:
return jsonify({"error": "scope must be all, user or workspace.", "kind": "invalid"}), 400

user_id = request.args.get("user_id", type=int)
workspace_id = request.args.get("workspace_id", type=int)
filters = {
"source": usage_dashboards.normalize_filter(request.args.get("source")),
"provider": usage_dashboards.normalize_filter(request.args.get("provider")),
"model": usage_dashboards.normalize_filter(request.args.get("model")),
}

current = datetime.now(UTC)
query = UsageRecord.query.filter(
UsageRecord.created_at >= current - timedelta(days=days),
UsageRecord.created_at <= current,
)
if scope == "user":
# An unknown or missing id yields an empty report rather than all rows.
query = query.filter(UsageRecord.user_id == (user_id if user_id is not None else -1))
elif scope == "workspace":
query = query.filter(
UsageRecord.workspace_id == (workspace_id if workspace_id is not None else -1)
)
if filters["source"]:
query = query.filter(UsageRecord.source == filters["source"])
if filters["provider"]:
query = query.filter(UsageRecord.provider == filters["provider"])
if filters["model"]:
query = query.filter(UsageRecord.model == filters["model"])

summary = usage_ledger.summarize_query(query)
page = max(1, request.args.get("page", 1, type=int) or 1)
per_page = min(
max(
1,
request.args.get("per_page", REPORT_DEFAULT_PER_PAGE, type=int)
or REPORT_DEFAULT_PER_PAGE,
),
REPORT_MAX_PER_PAGE,
)
pagination = query.order_by(UsageRecord.created_at.desc(), UsageRecord.id.desc()).paginate(
page=page, per_page=per_page, error_out=False
)

rows = pagination.items
user_ids = {row.user_id for row in rows if row.user_id is not None}
workspace_ids = {row.workspace_id for row in rows if row.workspace_id is not None}
users_by_id = (
{user.id: user for user in User.query.filter(User.id.in_(user_ids)).all()}
if user_ids
else {}
)
workspaces_by_id = (
{
workspace.id: workspace
for workspace in Workspace.query.filter(Workspace.id.in_(workspace_ids)).all()
}
if workspace_ids
else {}
)

return render_template(
"admin/usage_report.html",
days=days,
scope=scope,
user_id=user_id,
workspace_id=workspace_id,
filters=filters,
summary=summary,
rows=rows,
users_by_id=users_by_id,
workspaces_by_id=workspaces_by_id,
pagination=pagination,
per_page=per_page,
users=User.query.order_by(User.username, User.id).all(),
workspaces=Workspace.query.order_by(Workspace.name, Workspace.id).all(),
available_filters=usage_dashboards.available_filters(days=days, now=current),
currency=get_currency_config(),
)


@bp.route("/usage/export.csv")
@login_required
@admin_required
Expand Down
42 changes: 42 additions & 0 deletions app/billing/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,14 @@
return None, (jsonify({"error": str(exc), "kind": "invalid"}), 400)


def _parse_bucket():
"""Parse ``bucket`` and return ``(bucket, error_response)``."""
try:
return usage_dashboards.normalize_bucket(request.args.get("bucket")), None
except ValueError as exc:
return None, (jsonify({"error": str(exc), "kind": "invalid"}), 400)


def _parse_filters() -> dict:
"""Read the optional source/provider/model filters from the query string."""
return {
Expand Down Expand Up @@ -335,6 +343,22 @@
return jsonify(usage_dashboards.dashboard(user_id=current_user.id, days=days, **filters))


@bp.route("/api/usage/timeseries")
@login_required
def api_usage_timeseries():
"""Return the signed-in user's usage and cost as daily/weekly buckets (#449)."""
days, error = _parse_days()
if error is not None:
return error
bucket, error = _parse_bucket()
if error is not None:
return error
filters = _parse_filters()
return jsonify(
usage_dashboards.timeseries(user_id=current_user.id, days=days, bucket=bucket, **filters)
)


@bp.route("/api/usage/dashboard.csv")
@login_required
def api_usage_dashboard_csv():
Expand All @@ -360,6 +384,24 @@
return jsonify(usage_dashboards.dashboard(workspace_id=workspace_id, days=days, **filters))


@bp.route("/api/workspaces/<int:workspace_id>/usage/timeseries")
@login_required
def api_workspace_timeseries(workspace_id: int):
"""Return a workspace time series for its owner or a site administrator."""
if not can_access_workspace_billing(workspace_id, current_user):
return _dashboard_refusal()
days, error = _parse_days()
if error is not None:
return error
bucket, error = _parse_bucket()
if error is not None:
return error
filters = _parse_filters()
return jsonify(
usage_dashboards.timeseries(workspace_id=workspace_id, days=days, bucket=bucket, **filters)
)


@bp.route("/api/workspaces/<int:workspace_id>/usage.csv")
@login_required
def api_workspace_usage_csv(workspace_id: int):
Expand Down
3 changes: 3 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,9 @@ class Config:
# spend. The default warning threshold is applied to new budgets.
BUDGET_ENFORCEMENT_ENABLED = os.getenv("BUDGET_ENFORCEMENT_ENABLED", "1") == "1"
BUDGET_DEFAULT_WARN_THRESHOLD = float(os.getenv("BUDGET_DEFAULT_WARN_THRESHOLD", "0.8"))
# Optional recipient for over-budget alert email. In-app notifications are
# always created; email is best-effort and requires SMTP to be configured.
BILLING_ALERT_EMAIL = os.getenv("BILLING_ALERT_EMAIL", "")
# Display currency for every cost figure (issue #441). Defaults to USD; the
# code is always shown next to the symbol so a figure is never ambiguous.
# Operators that change the code are responsible for keeping the configured
Expand Down
Loading
Loading