From 8fbfa5a6e1587030d302a31ee6621cb4821be995 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 1 Jul 2026 17:53:42 +0200 Subject: [PATCH] feat(capabilities): add pre-flight meter-gate for billed verbs --- surfsense_backend/app/capabilities/billing.py | 31 ++++++++++++++++++- surfsense_backend/app/capabilities/types.py | 7 +++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/surfsense_backend/app/capabilities/billing.py b/surfsense_backend/app/capabilities/billing.py index ed1b00e13..20d1059bf 100644 --- a/surfsense_backend/app/capabilities/billing.py +++ b/surfsense_backend/app/capabilities/billing.py @@ -6,7 +6,12 @@ from typing import TYPE_CHECKING from sqlalchemy import select -from app.capabilities.types import BillableOutput, BillingUnit, CapabilityContext +from app.capabilities.types import ( + BillableInput, + BillableOutput, + BillingUnit, + CapabilityContext, +) from app.services.token_tracking_service import record_token_usage from app.services.web_crawl_credit_service import WebCrawlCreditService @@ -16,6 +21,30 @@ if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncSession +async def gate_capability( + payload: BillableInput, unit: BillingUnit | None, ctx: CapabilityContext +) -> None: + """Pre-flight: block an over-budget owner before the executor runs (03c). + + Raises ``InsufficientCreditsError`` when the wallet can't cover the input's + worst-case ``estimated_units``. ``None`` unit = free = no gate. + """ + if unit is None: + return + if unit is BillingUnit.WEB_CRAWL: + await _gate_web_crawl(ctx, payload.estimated_units) + + +async def _gate_web_crawl(ctx: CapabilityContext, estimated_successes: int) -> None: + service = WebCrawlCreditService(ctx.session) + if not service.billing_enabled(): + return + owner_user_id = await _resolve_workspace_owner(ctx.session, ctx.workspace_id) + if owner_user_id is None: + return + await service.check_credits(owner_user_id, estimated_successes) + + async def charge_capability( output: BillableOutput, unit: BillingUnit | None, ctx: CapabilityContext ) -> None: diff --git a/surfsense_backend/app/capabilities/types.py b/surfsense_backend/app/capabilities/types.py index 6cb69c312..4a8b57cbf 100644 --- a/surfsense_backend/app/capabilities/types.py +++ b/surfsense_backend/app/capabilities/types.py @@ -18,6 +18,13 @@ class BillingUnit(StrEnum): WEB_CRAWL = "web_crawl" +class BillableInput(Protocol): + """A billed verb's input that reports its worst-case unit count for pre-flight.""" + + @property + def estimated_units(self) -> int: ... + + class BillableOutput(Protocol): """A capability output that reports its own billable count."""