6ede33ccdb
Build and Push Docker Images / create_manifest (web, surfsense-web, , cpu) (push) Has been cancelled
Build and Push Docker Images / finalize_release (push) Has been cancelled
Obsidian Plugin Lint / lint (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_web, cpu, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-24.04-arm, linux/arm64, arm64, , runner, false, cpu) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_web, cpu, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-latest, linux/amd64, amd64, , runner, false, cpu) (push) Has been cancelled
Build and Push Docker Images / compute_version (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cpu, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, , production, false, cpu) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cpu, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, , production, false, cpu) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cu126, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, -cuda126, production, true, cuda126) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cu126, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, -cuda126, production, true, cuda126) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cu128, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, -cuda, production, true, cuda) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cu128, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, -cuda, production, true, cuda) (push) Has been cancelled
Build and Push Docker Images / verify_digests (push) Has been cancelled
Build and Push Docker Images / create_manifest (backend, surfsense-backend, , cpu) (push) Has been cancelled
Build and Push Docker Images / create_manifest (backend, surfsense-backend, -cuda, cuda) (push) Has been cancelled
Build and Push Docker Images / create_manifest (backend, surfsense-backend, -cuda126, cuda126) (push) Has been cancelled
285 lines
10 KiB
Python
285 lines
10 KiB
Python
"""Charge the workspace owner per billable success at the capability executor (03c)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING
|
|
|
|
from sqlalchemy import select
|
|
|
|
from app.capabilities.core.types import (
|
|
BillableInput,
|
|
BillableOutput,
|
|
BillingUnit,
|
|
CapabilityContext,
|
|
)
|
|
from app.config import config
|
|
from app.services import wallet_credit
|
|
from app.services.platform_scrape_credit_service import PlatformScrapeCreditService
|
|
from app.services.token_tracking_service import record_token_usage
|
|
from app.services.web_crawl_credit_service import WebCrawlCreditService
|
|
from app.utils.captcha import captcha_enabled
|
|
|
|
if TYPE_CHECKING:
|
|
from uuid import UUID
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
# Each platform meter -> the config knob holding its micro-USD per-item rate.
|
|
# The rate is looked up live (not cached) so an env retune + restart takes
|
|
# effect without a code change, mirroring the crawl biller.
|
|
_PLATFORM_RATE_KEYS: dict[BillingUnit, str] = {
|
|
BillingUnit.REDDIT_ITEM: "REDDIT_SCRAPE_MICROS_PER_ITEM",
|
|
BillingUnit.GOOGLE_SEARCH_SERP: "GOOGLE_SEARCH_MICROS_PER_SERP",
|
|
BillingUnit.GOOGLE_MAPS_PLACE: "GOOGLE_MAPS_MICROS_PER_PLACE",
|
|
BillingUnit.GOOGLE_MAPS_REVIEW: "GOOGLE_MAPS_MICROS_PER_REVIEW",
|
|
BillingUnit.YOUTUBE_VIDEO: "YOUTUBE_MICROS_PER_VIDEO",
|
|
BillingUnit.YOUTUBE_COMMENT: "YOUTUBE_MICROS_PER_COMMENT",
|
|
}
|
|
|
|
|
|
def _platform_rate(unit: BillingUnit) -> int:
|
|
"""Micro-USD per item for a platform meter, read live from config."""
|
|
return int(getattr(config, _PLATFORM_RATE_KEYS[unit]))
|
|
|
|
|
|
# Display noun for each platform meter, e.g. "$3.50 / 1k places".
|
|
_UNIT_NOUNS: dict[BillingUnit, str] = {
|
|
BillingUnit.REDDIT_ITEM: "item",
|
|
BillingUnit.GOOGLE_SEARCH_SERP: "SERP",
|
|
BillingUnit.GOOGLE_MAPS_PLACE: "place",
|
|
BillingUnit.GOOGLE_MAPS_REVIEW: "review",
|
|
BillingUnit.YOUTUBE_VIDEO: "video",
|
|
BillingUnit.YOUTUBE_COMMENT: "comment",
|
|
}
|
|
|
|
|
|
def pricing_meters(unit: BillingUnit | None) -> list[dict]:
|
|
"""The live per-item rates a verb charges, for UI display. Empty = free.
|
|
|
|
Mirrors the gate/charge logic exactly: meters whose billing flag is off are
|
|
omitted, so a self-hosted install with billing disabled reads as free.
|
|
"""
|
|
if unit is None:
|
|
return []
|
|
if unit is BillingUnit.WEB_CRAWL:
|
|
meters = []
|
|
if WebCrawlCreditService.billing_enabled():
|
|
meters.append(
|
|
{"unit": "page", "micros_per_unit": config.WEB_CRAWL_MICROS_PER_SUCCESS}
|
|
)
|
|
if WebCrawlCreditService.captcha_billing_enabled() and captcha_enabled():
|
|
meters.append(
|
|
{
|
|
"unit": "captcha solve",
|
|
"micros_per_unit": config.WEB_CRAWL_CAPTCHA_MICROS_PER_SOLVE,
|
|
}
|
|
)
|
|
return meters
|
|
if not config.PLATFORM_SCRAPE_BILLING_ENABLED:
|
|
return []
|
|
meters = [{"unit": _UNIT_NOUNS[unit], "micros_per_unit": _platform_rate(unit)}]
|
|
if unit is BillingUnit.GOOGLE_MAPS_PLACE:
|
|
# Dual-metered: attached reviews bill on their own meter.
|
|
meters.append(
|
|
{
|
|
"unit": _UNIT_NOUNS[BillingUnit.GOOGLE_MAPS_REVIEW],
|
|
"micros_per_unit": _platform_rate(BillingUnit.GOOGLE_MAPS_REVIEW),
|
|
}
|
|
)
|
|
return meters
|
|
|
|
|
|
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)
|
|
return
|
|
await _gate_platform(payload, unit, ctx)
|
|
|
|
|
|
async def _gate_web_crawl(ctx: CapabilityContext, estimated_successes: int) -> None:
|
|
"""Reserve the worst-case cost: crawl successes + worst-case captcha attempts.
|
|
|
|
Captcha budget is only reserved when solving is actually enabled — with
|
|
solving off, attempts can never happen, so reserving would wrongly block a
|
|
run for captcha that will never be attempted. Mirrors the indexer path (3d).
|
|
"""
|
|
service = WebCrawlCreditService(ctx.session)
|
|
crawl_on = service.billing_enabled()
|
|
captcha_on = service.captcha_billing_enabled() and captcha_enabled()
|
|
if not crawl_on and not captcha_on:
|
|
return
|
|
owner_user_id = await _resolve_workspace_owner(ctx.session, ctx.workspace_id)
|
|
if owner_user_id is None:
|
|
return
|
|
|
|
required_micros = 0
|
|
if crawl_on:
|
|
required_micros += service.successes_to_micros(estimated_successes)
|
|
if captcha_on:
|
|
worst_case_attempts = estimated_successes * config.CAPTCHA_MAX_ATTEMPTS_PER_URL
|
|
required_micros += service.captcha_solves_to_micros(worst_case_attempts)
|
|
await service.check_balance(owner_user_id, required_micros)
|
|
|
|
|
|
async def _gate_platform(
|
|
payload: BillableInput, unit: BillingUnit, ctx: CapabilityContext
|
|
) -> None:
|
|
"""Reserve the worst-case per-item cost for a platform scraper verb.
|
|
|
|
``google_maps.scrape`` is dual-metered: it can attach reviews per place, so
|
|
its gate also reserves ``estimated_review_units`` at the review rate — same
|
|
two-meters-one-verb shape as crawl + captcha.
|
|
"""
|
|
service = PlatformScrapeCreditService(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
|
|
|
|
required_micros = service.items_to_micros(
|
|
payload.estimated_units, _platform_rate(unit)
|
|
)
|
|
if unit is BillingUnit.GOOGLE_MAPS_PLACE:
|
|
review_units = getattr(payload, "estimated_review_units", 0)
|
|
required_micros += service.items_to_micros(
|
|
review_units, _platform_rate(BillingUnit.GOOGLE_MAPS_REVIEW)
|
|
)
|
|
await wallet_credit.check_balance(ctx.session, owner_user_id, required_micros)
|
|
|
|
|
|
async def charge_capability(
|
|
output: BillableOutput, unit: BillingUnit | None, ctx: CapabilityContext
|
|
) -> int:
|
|
"""Bill the workspace owner for this result and return the micros charged.
|
|
|
|
For crawl-backed verbs this also bills any captcha *attempts* (Phase 3d) as a
|
|
separate per-attempt unit — the solver charges per attempt even when the crawl
|
|
ultimately failed, so it can't ride the per-success crawl meter. Platform
|
|
verbs bill per item returned; ``google_maps.scrape`` additionally bills its
|
|
attached reviews. ``None`` unit = free = returns 0.
|
|
|
|
The returned total lets the doors persist a per-run ``cost_micros``.
|
|
"""
|
|
if unit is None:
|
|
return 0
|
|
if unit is BillingUnit.WEB_CRAWL:
|
|
charged = await _charge_web_crawl(ctx, output.billable_units)
|
|
charged += await _charge_captcha(ctx, getattr(output, "captcha_attempts", 0))
|
|
return charged
|
|
return await _charge_platform(output, unit, ctx)
|
|
|
|
|
|
async def _charge_web_crawl(ctx: CapabilityContext, successes: int) -> int:
|
|
if successes <= 0:
|
|
return 0
|
|
service = WebCrawlCreditService(ctx.session)
|
|
if not service.billing_enabled():
|
|
return 0
|
|
owner_user_id = await _resolve_workspace_owner(ctx.session, ctx.workspace_id)
|
|
if owner_user_id is None:
|
|
return 0
|
|
cost_micros = service.successes_to_micros(successes)
|
|
# Stage the audit row before charge_credits' commit flushes both.
|
|
await record_token_usage(
|
|
ctx.session,
|
|
usage_type="web_crawl",
|
|
workspace_id=ctx.workspace_id,
|
|
user_id=owner_user_id,
|
|
cost_micros=cost_micros,
|
|
call_details={"successes": successes},
|
|
)
|
|
await service.charge_credits(owner_user_id, successes)
|
|
return cost_micros
|
|
|
|
|
|
async def _charge_captcha(ctx: CapabilityContext, attempts: int) -> int:
|
|
if attempts <= 0:
|
|
return 0
|
|
service = WebCrawlCreditService(ctx.session)
|
|
if not service.captcha_billing_enabled():
|
|
return 0
|
|
owner_user_id = await _resolve_workspace_owner(ctx.session, ctx.workspace_id)
|
|
if owner_user_id is None:
|
|
return 0
|
|
cost_micros = service.captcha_solves_to_micros(attempts)
|
|
# Stage the audit row before charge_captcha's commit flushes both.
|
|
await record_token_usage(
|
|
ctx.session,
|
|
usage_type="web_crawl_captcha",
|
|
workspace_id=ctx.workspace_id,
|
|
user_id=owner_user_id,
|
|
cost_micros=cost_micros,
|
|
call_details={"attempts": attempts},
|
|
)
|
|
await service.charge_captcha(owner_user_id, attempts)
|
|
return cost_micros
|
|
|
|
|
|
async def _charge_platform(
|
|
output: BillableOutput, unit: BillingUnit, ctx: CapabilityContext
|
|
) -> int:
|
|
"""Charge a platform verb per item; dual-meter ``google_maps.scrape`` reviews."""
|
|
service = PlatformScrapeCreditService(ctx.session)
|
|
if not service.billing_enabled():
|
|
return 0
|
|
owner_user_id = await _resolve_workspace_owner(ctx.session, ctx.workspace_id)
|
|
if owner_user_id is None:
|
|
return 0
|
|
|
|
charged = await _charge_platform_meter(
|
|
service, ctx, owner_user_id, unit, output.billable_units
|
|
)
|
|
if unit is BillingUnit.GOOGLE_MAPS_PLACE:
|
|
reviews = getattr(output, "attached_review_count", 0)
|
|
charged += await _charge_platform_meter(
|
|
service, ctx, owner_user_id, BillingUnit.GOOGLE_MAPS_REVIEW, reviews
|
|
)
|
|
return charged
|
|
|
|
|
|
async def _charge_platform_meter(
|
|
service: PlatformScrapeCreditService,
|
|
ctx: CapabilityContext,
|
|
owner_user_id: UUID,
|
|
unit: BillingUnit,
|
|
items: int,
|
|
) -> int:
|
|
if items <= 0:
|
|
return 0
|
|
rate = _platform_rate(unit)
|
|
cost_micros = service.items_to_micros(items, rate)
|
|
# Stage the audit row before charge's commit flushes both.
|
|
await record_token_usage(
|
|
ctx.session,
|
|
usage_type=unit.value,
|
|
workspace_id=ctx.workspace_id,
|
|
user_id=owner_user_id,
|
|
cost_micros=cost_micros,
|
|
call_details={"items": items},
|
|
)
|
|
await service.charge(owner_user_id, items, rate)
|
|
return cost_micros
|
|
|
|
|
|
async def _resolve_workspace_owner(
|
|
session: AsyncSession, workspace_id: int
|
|
) -> UUID | None:
|
|
"""The ``user_id`` that owns ``workspace_id`` (the crawl payer, not the caller)."""
|
|
from app.db import Workspace
|
|
|
|
result = await session.execute(
|
|
select(Workspace.user_id).where(Workspace.id == workspace_id)
|
|
)
|
|
return result.scalar_one_or_none()
|