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
32 changes: 29 additions & 3 deletions apps/api/core/credits.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,16 +256,42 @@ async def check_run_credit_cap(db, agent_id, additional_cost: int) -> None:
})


async def credits_used_this_cycle(conn, user_id: str) -> int:
"""Authoritative credits consumed this billing cycle (calendar month) for a
user — summed from the immutable credit_transactions ledger.

Replaces the drift-prone api_keys.monthly_calls_count for every display/quota
surface. monthly_calls_count was maintained by a separate non-atomic path
(_increment_calls) that under-counted — it never saw NULL-api_key debits like
/search and the LLM path under-incremented — so any figure derived from it
(remaining quota, "used this cycle", invoice) disagreed with the real spend.
Summing the ledger ties every surface to user_credits.credits_balance."""
used = await conn.fetchval("""
SELECT COALESCE(SUM(-amount), 0)
FROM credit_transactions
WHERE user_id = $1::uuid AND amount < 0
AND type IN ('execution', 'cross_rail', 'cloud_compute')
AND created_at >= date_trunc('month', NOW())
""", user_id)
return int(used or 0)


async def compute_calls_remaining(conn, api_key_id: str) -> int:
"""Single source of truth for calls_remaining. Reads monthly_calls_count directly — never uses credit math."""
"""Credits remaining in the cycle allotment — DERIVED from the ledger.

= plan allotment − credits_used_this_cycle (ledger sum), per USER (not per
key), so it captures all spend including NULL-key /search debits. No longer
reads api_keys.monthly_calls_count, which drifted low and over-stated
remaining (the pre-money quota under-count)."""
row = await conn.fetchrow(
"SELECT monthly_calls_count, tier FROM api_keys WHERE id = $1::uuid",
"SELECT user_id, tier FROM api_keys WHERE id = $1::uuid",
api_key_id,
)
if not row:
return 0
p = PLANS.get(row["tier"], PLANS["free"])
return max(0, p["calls_included"] - row["monthly_calls_count"])
used = await credits_used_this_cycle(conn, str(row["user_id"]))
return max(0, p["calls_included"] - used)


async def _maybe_send_usage_warning_email(pool, user_id: str, calls_remaining: int, percent_used: int, tier: str) -> None:
Expand Down
11 changes: 7 additions & 4 deletions apps/api/routers/billing/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from fastapi import APIRouter, Depends, HTTPException, Request

from core.auth import resolve_dashboard_caller
from core.credits import PLANS, CREDITS_PER_CALL, ROUTING_FEE, PAYMENT_MULTIPLIERS, compute_calls_remaining
from core.credits import PLANS, CREDITS_PER_CALL, ROUTING_FEE, PAYMENT_MULTIPLIERS, compute_calls_remaining, credits_used_this_cycle
from core.db import get_db
from core.rate_limit import limiter
from core.tier_gates import require_tier, _get_redis
Expand Down Expand Up @@ -303,7 +303,8 @@ def _kr(field, default=None):

# Forecast — daily average in credits (not calls). Returns null if < 3 days history.
forecast = None
monthly_credits_consumed = _kr("monthly_calls_count", 0) or 0
# Ledger-derived (NOT api_keys.monthly_calls_count, which drifts low).
monthly_credits_consumed = await credits_used_this_cycle(db, user_id)
if monthly_reset_at:
now_utc = datetime.now(timezone.utc)
period_start = monthly_reset_at.replace(tzinfo=timezone.utc) - timedelta(days=30)
Expand Down Expand Up @@ -455,7 +456,9 @@ async def account_analytics(request: Request, db=Depends(get_db)):
"WHERE user_id=$1 AND type='execution' AND created_at >= date_trunc('month', NOW()) "
"AND service_id IS NOT NULL GROUP BY service_id ORDER BY count DESC LIMIT 10", user_id)

# ── Calls — source of truth: monthly_calls_count on api_keys ─────────────
# ── Credits used this cycle — source of truth: the credit_transactions
# ledger (NOT api_keys.monthly_calls_count, which drifts low and under-counts
# NULL-key /search debits). Ties this "used this cycle" figure to the balance.
today = _datetime.date.today()
if today.month == 12:
reset = _datetime.date(today.year + 1, 1, 1)
Expand Down Expand Up @@ -520,7 +523,7 @@ def _find_v2(slug: str):
plan_tier = credits["package_tier"] if credits else "free"
plan_def = PLANS.get(plan_tier, PLANS["free"])
credits_included = plan_def["calls_included"] # calls_included == monthly_credits after fix
credits_used = caller["monthly_calls_count"] or 0
credits_used = await credits_used_this_cycle(db, user_id)
credits_remaining = max(0, credits_included - credits_used)
reset_at_str = (
caller["monthly_calls_reset_at"].date().isoformat()
Expand Down
32 changes: 28 additions & 4 deletions apps/api/routers/cloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -595,18 +595,27 @@ async def list_agents(request: Request, db=Depends(get_db)) -> dict:
user_id, _, tier = await _resolve_caller(request, db)
require_tier(tier, "cloud_agents")

# credits_30d is DERIVED from the credit_transactions ledger (the authoritative
# spend record), not SUM(agent_runs.credits_total) — a denormalized per-run
# column that can lag/zero out and drift from what was actually billed. Cloud
# run debits are tagged with the run id (X_WAYFORTH_AGENT_ID), so we join the
# ledger to the agent's runs.
rows = await db.fetch("""
SELECT
ha.id, ha.name, ha.slug, ha.runtime, ha.status,
ha.trigger_type, ha.schedule, ha.credit_cap,
ha.last_run_at, ha.created_at,
COALESCE(SUM(ar.credits_total) FILTER (
WHERE ar.created_at >= NOW() - INTERVAL '30 days'
COALESCE((
SELECT SUM(-ct.amount)
FROM credit_transactions ct
JOIN agent_runs ar ON ct.agent_id = ar.id::text
WHERE ar.hosted_agent_id = ha.id
AND ct.amount < 0
AND ct.type IN ('execution', 'cloud_compute', 'cross_rail')
AND ct.created_at >= NOW() - INTERVAL '30 days'
), 0) AS credits_30d
FROM hosted_agents ha
LEFT JOIN agent_runs ar ON ar.hosted_agent_id = ha.id
WHERE ha.user_id = $1::uuid
GROUP BY ha.id
ORDER BY ha.created_at DESC
LIMIT 100
""", user_id)
Expand Down Expand Up @@ -640,6 +649,20 @@ async def get_agent(request: Request, agent_id: str, db=Depends(get_db)) -> dict

agent = await _get_agent_or_404(db, user_id, agent_id)

# CREDITS (30D) for the detail page — DERIVED from the credit_transactions
# ledger (authoritative spend), not a denormalized column. This field was
# previously absent from the detail endpoint entirely, so the dashboard had
# nothing to read and showed 0. Cloud run debits are tagged with the run id.
credits_30d = await db.fetchval("""
SELECT COALESCE(SUM(-ct.amount), 0)
FROM credit_transactions ct
JOIN agent_runs ar ON ct.agent_id = ar.id::text
WHERE ar.hosted_agent_id = $1::uuid
AND ct.amount < 0
AND ct.type IN ('execution', 'cloud_compute', 'cross_rail')
AND ct.created_at >= NOW() - INTERVAL '30 days'
""", agent_id)

# Last run summary
last_run = await db.fetchrow("""
SELECT id, status, trigger, started_at, completed_at, duration_ms,
Expand All @@ -659,6 +682,7 @@ async def get_agent(request: Request, agent_id: str, db=Depends(get_db)) -> dict
"schedule": agent["schedule"],
"credit_cap": agent["credit_cap"],
"concurrent_max": agent.get("concurrent_max") or 1,
"credits_30d": int(credits_30d or 0),
"sandbox_provider": agent["sandbox_provider"],
"webhook_id": str(agent["webhook_id"]) if agent.get("webhook_id") else None,
"next_run_at": agent["next_run_at"].isoformat() if agent.get("next_run_at") else None,
Expand Down
Loading