Implement SAND-WP-0006: SaaS payments, routing, and ext.saas-stub
Add credits store, metering on create/destroy, extension routing resolver, metered SaaS stub extension, burst/saas profiles, credits CLI, docs, and tests.
This commit is contained in:
parent
eee336149e
commit
1415e17230
29 changed files with 878 additions and 18 deletions
6
src/sandboxer/payments/__init__.py
Normal file
6
src/sandboxer/payments/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
"""Payments and metering for SaaS sandbox extensions."""
|
||||
|
||||
from sandboxer.payments.credits import CreditsStore
|
||||
from sandboxer.payments.metering import estimate_cost, settle_usage
|
||||
|
||||
__all__ = ["CreditsStore", "estimate_cost", "settle_usage"]
|
||||
48
src/sandboxer/payments/credits.py
Normal file
48
src/sandboxer/payments/credits.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"""Org/workspace credits for metered sandbox consumption."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _default_credits_path() -> Path:
|
||||
base = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
|
||||
return base / "sandboxer" / "credits.json"
|
||||
|
||||
|
||||
class CreditsStore:
|
||||
def __init__(self, path: Path | None = None) -> None:
|
||||
self.path = path or _default_credits_path()
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _read(self) -> dict:
|
||||
if not self.path.exists():
|
||||
default = float(os.environ.get("SANDBOXER_DEFAULT_CREDITS", "10.0"))
|
||||
return {"balance_usd": default, "currency": "USD"}
|
||||
return json.loads(self.path.read_text())
|
||||
|
||||
def _write(self, data: dict) -> None:
|
||||
self.path.write_text(json.dumps(data, indent=2))
|
||||
|
||||
def balance(self) -> float:
|
||||
return float(self._read().get("balance_usd", 0.0))
|
||||
|
||||
def can_afford(self, amount_usd: float) -> bool:
|
||||
return self.balance() >= amount_usd
|
||||
|
||||
def add(self, amount_usd: float) -> float:
|
||||
data = self._read()
|
||||
data["balance_usd"] = round(float(data.get("balance_usd", 0.0)) + amount_usd, 4)
|
||||
self._write(data)
|
||||
return data["balance_usd"]
|
||||
|
||||
def debit(self, amount_usd: float) -> float:
|
||||
data = self._read()
|
||||
new_balance = round(float(data.get("balance_usd", 0.0)) - amount_usd, 4)
|
||||
if new_balance < 0:
|
||||
raise ValueError(f"Insufficient credits: need {amount_usd:.4f} USD")
|
||||
data["balance_usd"] = new_balance
|
||||
self._write(data)
|
||||
return new_balance
|
||||
66
src/sandboxer/payments/metering.py
Normal file
66
src/sandboxer/payments/metering.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
"""Cost estimation and usage settlement for metered extensions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sandboxer.extensions.registry import resolve_backend
|
||||
from sandboxer.models import Extension, MeterQuote, MeterRecord, Profile, SandboxStatus
|
||||
|
||||
|
||||
def _duration_seconds(ready_at: datetime | None, destroyed_at: datetime) -> float:
|
||||
if not ready_at:
|
||||
return 0.0
|
||||
return max(0.0, (destroyed_at - ready_at).total_seconds())
|
||||
|
||||
|
||||
def estimate_cost(
|
||||
extension: Extension,
|
||||
profile: Profile,
|
||||
inputs: dict[str, str],
|
||||
*,
|
||||
duration_s: int = 3600,
|
||||
) -> MeterQuote | None:
|
||||
if extension.capabilities.pricing_model != "metered":
|
||||
return None
|
||||
backend = resolve_backend(extension)
|
||||
if not hasattr(backend, "estimate_cost"):
|
||||
return None
|
||||
quote = backend.estimate_cost(profile, inputs, duration_s=duration_s)
|
||||
if quote is None:
|
||||
return None
|
||||
if isinstance(quote, MeterQuote):
|
||||
return quote
|
||||
if isinstance(quote, dict):
|
||||
return MeterQuote.model_validate(quote)
|
||||
return None
|
||||
|
||||
|
||||
def settle_usage(
|
||||
status: SandboxStatus,
|
||||
extension: Extension,
|
||||
handle: dict[str, str],
|
||||
*,
|
||||
destroyed_at: datetime,
|
||||
) -> MeterRecord | None:
|
||||
if extension.capabilities.pricing_model != "metered":
|
||||
return MeterRecord(pricing_model="self-hosted")
|
||||
|
||||
duration_s = _duration_seconds(status.ready_at, destroyed_at)
|
||||
backend = resolve_backend(extension)
|
||||
actual_usd: float | None = None
|
||||
|
||||
if hasattr(backend, "meter_actual"):
|
||||
actual_usd = backend.meter_actual(handle, duration_s=duration_s)
|
||||
|
||||
if actual_usd is None and status.meter and status.meter.estimate_usd is not None:
|
||||
hours = duration_s / 3600.0
|
||||
actual_usd = round(status.meter.estimate_usd * max(hours, 1 / 3600), 4)
|
||||
|
||||
estimate = status.meter.estimate_usd if status.meter else None
|
||||
return MeterRecord(
|
||||
pricing_model="metered",
|
||||
estimate_usd=estimate,
|
||||
actual_usd=actual_usd,
|
||||
duration_s=round(duration_s, 1),
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue