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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ LLM_CACHE_ENABLED=1
LLM_CACHE_TTL=300
LLM_CACHE_MAX_ENTRIES=256

# Display currency for cost estimates (Phase 10, issue #441). Costs are always
# estimates, so every figure shows the currency code explicitly. Keep the
# configured estimate/price rates in this currency when overriding the default.
CURRENCY_CODE=USD
CURRENCY_SYMBOL=$
CURRENCY_DECIMAL_PLACES=4

# Plugin manifest trust (optional). Unsigned plugins remain allowed by default.
# Set PLUGIN_TRUST_POLICY=required to reject unsigned or invalid signatures.
# JSON object mapping publisher ids to base64 Ed25519 public keys.
Expand Down
27 changes: 26 additions & 1 deletion app/chat/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
POST /api/conversations/<id>/messages send a message (LLM reply)
DELETE /api/conversations/<id> delete (cascade)
GET /api/usage per-user token usage summary
GET /api/usage/breakdown usage and cost by source

All routes require authentication and are owner-scoped. Errors use RFC 7807
(``application/problem+json``) documents so API clients never receive HTML error
Expand All @@ -26,7 +27,8 @@
from app.chat import routes as chat_routes
from app.extensions import db
from app.models import Conversation, Message
from app.services import ratelimit, streaming
from app.services import ratelimit, streaming, usage_ledger
from app.services.currency import get_currency_config
from app.services.llm import LLMProviderError, provider_status
from app.services.provider_config import ProviderSettingsError, apply_settings, build_provider
from app.services.providers.retry import RetryingProvider
Expand Down Expand Up @@ -294,3 +296,26 @@ def usage_summary():
"total_tokens": total,
}
)


@bp.route("/usage/breakdown", methods=["GET"])
@_login_required
@_rate_limit("usage")
def usage_breakdown():
"""Return usage counts and estimated cost grouped by ledger source.

The authenticated user's records are always the outer scope, so an optional
``workspace_id`` or ``project_id`` filter can never expose another account's
usage. Unknown historical sources are grouped under ``unknown`` rather than
dropped or causing a 500.
"""
workspace_id = request.args.get("workspace_id", type=int)
project_id = request.args.get("project_id", type=int)
payload = usage_ledger.breakdown_payload(
user_id=current_user.id,
workspace_id=workspace_id,
project_id=project_id,
)
payload["currency"] = get_currency_config()
payload["cost_is_estimate"] = True
return jsonify(payload)
9 changes: 8 additions & 1 deletion app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,15 @@ class Config:
LLM_CACHE_TTL = int(os.getenv("LLM_CACHE_TTL", "300"))
LLM_CACHE_MAX_ENTRIES = int(os.getenv("LLM_CACHE_MAX_ENTRIES", "256"))
# Pre-flight token/cost estimate (issue #104). The rate is only used to turn
# an estimated token count into a rough USD figure shown before sending.
# an estimated token count into a rough figure shown before sending.
LLM_ESTIMATE_USD_PER_1K_TOKENS = float(os.getenv("LLM_ESTIMATE_USD_PER_1K_TOKENS", "0.002"))
# 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
# price/estimate rates in the same currency.
CURRENCY_CODE = os.getenv("CURRENCY_CODE", "USD")
CURRENCY_SYMBOL = os.getenv("CURRENCY_SYMBOL", "$")
CURRENCY_DECIMAL_PLACES = int(os.getenv("CURRENCY_DECIMAL_PLACES", "4"))
# Structured chat audit logging (issue #17). ``CHAT_AUDIT_LOG_LEVEL`` may be
# any logging level or OFF to disable.
CHAT_AUDIT_LOG_ENABLED = os.getenv("CHAT_AUDIT_LOG_ENABLED", "1") == "1"
Expand Down
2 changes: 2 additions & 0 deletions app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
from app.models.review_config import ReviewConfig
from app.models.review_finding import ReviewFinding
from app.models.stellar_security_finding import StellarSecurityFinding
from app.models.usage_record import UsageRecord
from app.models.user import User
from app.models.workspace import Workspace
from app.models.workspace_member import WorkspaceMember
Expand Down Expand Up @@ -65,6 +66,7 @@
"ReviewConfig",
"ReviewFinding",
"StellarSecurityFinding",
"UsageRecord",
"User",
"Workspace",
"WorkspaceInvitation",
Expand Down
95 changes: 95 additions & 0 deletions app/models/usage_record.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""Central usage ledger record (Phase 10).

Each row is an immutable accounting fact for one LLM operation: how many
tokens were consumed, which source produced the work, and which user,
workspace, project and conversation it belongs to. Prompts and completions are
deliberately **not** stored here; only metadata and counts are.

Usage is scoped with nullable foreign keys so a record never has to be deleted
when a container is removed:

- deleting a user cascades to their ledger rows;
- deleting a workspace, project or conversation leaves the accounting row in
place and nulls the corresponding reference (``ON DELETE SET NULL``).
"""

from datetime import UTC, datetime

from app.extensions import db


class UsageRecord(db.Model):
"""One append-only usage entry in the central ledger."""

__tablename__ = "usage_records"

id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(
db.Integer,
db.ForeignKey("users.id", ondelete="CASCADE"),
nullable=True,
index=True,
)
workspace_id = db.Column(
db.Integer,
db.ForeignKey("workspaces.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
project_id = db.Column(
db.Integer,
db.ForeignKey("projects.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
conversation_id = db.Column(
db.Integer,
db.ForeignKey("conversations.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
#: Operation family: chat, analysis, review, tools or project_chat.
#: Unknown values are preserved so readers can group them safely.
source = db.Column(db.String(40), nullable=False, default="unknown", index=True)
provider = db.Column(db.String(50), nullable=True)
model = db.Column(db.String(100), nullable=True)
prompt_tokens = db.Column(db.Integer, nullable=False, default=0)
completion_tokens = db.Column(db.Integer, nullable=False, default=0)
total_tokens = db.Column(db.Integer, nullable=False, default=0)
#: True when any token count was estimated rather than reported by the
#: provider (streamed replies, the offline mock provider, pre-flight work).
tokens_estimated = db.Column(db.Boolean, nullable=False, default=True)
#: Estimated cost in the configured display currency. ``None`` means the
#: provider/model has no known price; it is never silently reported as zero.
cost_estimate = db.Column(db.Float, nullable=True)
created_at = db.Column(
db.DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(UTC),
index=True,
)

def to_dict(self) -> dict:
"""Serialize the record for JSON responses, logs and exports."""
return {
"id": self.id,
"user_id": self.user_id,
"workspace_id": self.workspace_id,
"project_id": self.project_id,
"conversation_id": self.conversation_id,
"source": self.source,
"provider": self.provider,
"model": self.model,
"prompt_tokens": self.prompt_tokens,
"completion_tokens": self.completion_tokens,
"total_tokens": self.total_tokens,
"tokens_estimated": bool(self.tokens_estimated),
"cost_estimate": self.cost_estimate,
"created_at": self.created_at.isoformat() if self.created_at else None,
}

def __repr__(self) -> str: # pragma: no cover - debugging aid
return (
f"<UsageRecord id={self.id} source={self.source!r} "
f"total_tokens={self.total_tokens}>"
)
128 changes: 128 additions & 0 deletions app/services/currency.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"""Currency configuration and cost formatting (Phase 10, issue #441).

The ledger and price table work in one configured display currency, defaulting
to USD. Cost figures are estimates (see :mod:`app.services.pricing`), so every
formatted value includes both the currency code and an ``estimate`` marker made
available to callers.

Formatting is centralised here instead of being duplicated in templates and
JavaScript so every cost figure uses the same symbol, precision and code.
"""

from __future__ import annotations

from decimal import ROUND_HALF_UP, Decimal, InvalidOperation

DEFAULT_CURRENCY_CODE = "USD"
DEFAULT_CURRENCY_SYMBOL = "$"
DEFAULT_DECIMAL_PLACES = 4

#: Suffix that makes it unambiguous that a figure is not an invoice amount.
ESTIMATE_SUFFIX = "estimate"


def _clean_code(value) -> str:
code = str(value or "").strip().upper()
return code[:8] or DEFAULT_CURRENCY_CODE


def _clean_symbol(value) -> str:
symbol = str(value or "").strip()
return symbol[:8]


def _clean_places(value) -> int:
try:
places = int(value)
except (TypeError, ValueError):
return DEFAULT_DECIMAL_PLACES
return min(max(places, 0), 8)


def normalize_currency(config: dict | None = None) -> dict:
"""Return a complete, validated currency configuration.

Missing or malformed values fall back to USD rather than failing a page
render or API request. Unknown keys are ignored.
"""
config = config or {}
code = _clean_code(config.get("code"))
symbol = _clean_symbol(config.get("symbol", DEFAULT_CURRENCY_SYMBOL))
places = _clean_places(config.get("decimal_places", DEFAULT_DECIMAL_PLACES))
return {"code": code, "symbol": symbol, "decimal_places": places}


def get_currency_config(app=None) -> dict:
"""Read the configured display currency from the Flask application.

Falls back to the USD defaults when called outside an application context
(for example from a unit test or a formatting helper), so formatting never
fails merely because no app is bound.
"""
if app is None:
from flask import current_app

try:
config = current_app.config
except RuntimeError:
config = {}
else:
config = app.config
return normalize_currency(
{
"code": config.get("CURRENCY_CODE", DEFAULT_CURRENCY_CODE),
"symbol": config.get("CURRENCY_SYMBOL", DEFAULT_CURRENCY_SYMBOL),
"decimal_places": config.get("CURRENCY_DECIMAL_PLACES", DEFAULT_DECIMAL_PLACES),
}
)


def format_cost(amount, config: dict | None = None) -> str:
"""Format ``amount`` with the configured currency symbol, code and precision.

``amount`` must be a finite, non-negative number. Values are rounded
half-up to the configured number of decimal places, then the code is shown
explicitly (for example ``$0.0025 USD``) so the display is never ambiguous.
"""
currency = normalize_currency(config) if config is not None else get_currency_config()
try:
value = Decimal(str(amount if amount is not None else 0))
except (InvalidOperation, ValueError, TypeError) as exc:
raise ValueError("Cost amount must be numeric.") from exc
if not value.is_finite():
raise ValueError("Cost amount must be finite.")
if value < 0:
raise ValueError("Cost amount must be non-negative.")

quantizer = Decimal(1).scaleb(-currency["decimal_places"])
rounded = value.quantize(quantizer, rounding=ROUND_HALF_UP)
number = f"{rounded:,.{currency['decimal_places']}f}"
symbol = currency["symbol"]
if symbol:
return f"{symbol}{number} {currency['code']}"
return f"{number} {currency['code']}"


def format_estimate(tokens, amount, config: dict | None = None) -> str:
"""Format a pre-flight estimate with tokens and an explicit cost label."""
currency = normalize_currency(config) if config is not None else get_currency_config()
try:
token_count = max(0, int(tokens or 0))
except (TypeError, ValueError):
token_count = 0
return (
f"Estimated: ~{token_count:,} tokens "
f"({format_cost(amount, currency)}; {ESTIMATE_SUFFIX})"
)


__all__ = [
"DEFAULT_CURRENCY_CODE",
"DEFAULT_CURRENCY_SYMBOL",
"DEFAULT_DECIMAL_PLACES",
"ESTIMATE_SUFFIX",
"format_cost",
"format_estimate",
"get_currency_config",
"normalize_currency",
]
Loading
Loading