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:
tegwick 2026-06-24 07:52:20 +02:00
parent eee336149e
commit 1415e17230
29 changed files with 878 additions and 18 deletions

View file

@ -11,6 +11,7 @@ from sandboxer import __version__
from sandboxer.core.manager import SandboxManager
from sandboxer.defaults import resolve_create_defaults
from sandboxer.models import ActorType, Consumer, SandboxCreateRequest
from sandboxer.payments.credits import CreditsStore
from sandboxer.placement import resolve_host
from sandboxer.profiles.loader import load_profile
from sandboxer.telemetry.export import export_telemetry
@ -25,6 +26,8 @@ app = typer.Typer(
)
inspect_app = typer.Typer(help="Host introspection without provisioning.")
app.add_typer(inspect_app, name="inspect")
credits_app = typer.Typer(help="SaaS sandbox credits (metered extensions).")
app.add_typer(credits_app, name="credits")
@app.callback()
@ -216,5 +219,22 @@ def reap_stale_cmd(
_print_json([r.model_dump(mode="json") for r in results])
@credits_app.command("show")
def credits_show() -> None:
"""Show current credit balance."""
store = CreditsStore()
_print_json({"balance_usd": store.balance(), "currency": "USD"})
@credits_app.command("add")
def credits_add(
amount: Annotated[float, typer.Argument(help="USD amount to add")],
) -> None:
"""Add credits to the workspace balance."""
store = CreditsStore()
new_balance = store.add(amount)
typer.echo(f"Balance: {new_balance:.4f} USD")
if __name__ == "__main__":
app()

View file

@ -6,13 +6,17 @@ from sandboxer.extensions.registry import load_extension, resolve_backend
from sandboxer.lifecycle.state_hub import emit_lifecycle_event, event_type_for_state
from sandboxer.lifecycle.store import SandboxStore, utcnow
from sandboxer.models import (
MeterRecord,
Reachability,
SandboxCreateRequest,
SandboxState,
SandboxStatus,
)
from sandboxer.payments.credits import CreditsStore
from sandboxer.payments.metering import estimate_cost, settle_usage
from sandboxer.placement import resolve_host
from sandboxer.profiles.loader import load_profile
from sandboxer.routing.resolver import resolve_extension
from sandboxer.telemetry.export import export_telemetry
from sandboxer.telemetry.introspection import (
build_introspection_report,
@ -22,17 +26,40 @@ from sandboxer.telemetry.introspection import (
class SandboxManager:
def __init__(self, store: SandboxStore | None = None) -> None:
def __init__(
self,
store: SandboxStore | None = None,
credits: CreditsStore | None = None,
) -> None:
self.store = store or SandboxStore()
self.credits = credits or CreditsStore()
def _resolved_host(self, profile, extension, host_override: str | None) -> str:
if extension.capabilities.pricing_model == "metered":
return extension.config.get("provider", "saas")
return resolve_host(profile, override=host_override)
def create(self, request: SandboxCreateRequest, *, host: str | None = None) -> SandboxStatus:
profile = load_profile(request.profile)
extension = load_extension(profile.extension)
extension = resolve_extension(profile, request.inputs, host_override=host)
backend = resolve_backend(extension)
resolved_host = resolve_host(profile, override=host)
resolved_host = self._resolved_host(profile, extension, host)
wants_telemetry = profile_wants_telemetry(profile)
base_dir = extension.config.get("base_dir", "/tmp/sandboxer")
quote = estimate_cost(extension, profile, request.inputs)
meter_record: MeterRecord | None = None
if quote:
if not self.credits.can_afford(quote.estimated_usd):
raise RuntimeError(
f"Insufficient credits: need {quote.estimated_usd:.4f} USD, "
f"balance {self.credits.balance():.4f} USD"
)
meter_record = MeterRecord(
pricing_model="metered",
estimate_usd=quote.estimated_usd,
)
now = utcnow()
status = SandboxStatus(
sandbox_id="pending",
@ -42,6 +69,7 @@ class SandboxManager:
consumer=request.consumer,
host=resolved_host,
inputs=dict(request.inputs),
meter=meter_record,
created_at=now,
updated_at=now,
)
@ -52,7 +80,7 @@ class SandboxManager:
emit_lifecycle_event(status, event_type=event_type_for_state(status.state))
provision_before = None
if wants_telemetry:
if wants_telemetry and extension.capabilities.pricing_model != "metered":
provision_before = collect_host_snapshot(resolved_host)
try:
@ -64,6 +92,7 @@ class SandboxManager:
status.inputs["ssh_port"] = handle.get("ssh_port", "")
status.inputs["vm_target"] = handle.get("vm_target", "")
status.inputs["vm_host"] = handle.get("vm_host", "")
status.inputs["endpoint"] = handle.get("endpoint", "")
reach = backend.wait_ready(handle)
status.reachability = Reachability(**reach)
status.state = SandboxState.READY
@ -114,13 +143,13 @@ class SandboxManager:
return status
profile = load_profile(status.profile_id)
extension = load_extension(profile.extension)
extension = load_extension(status.extension_id)
backend = resolve_backend(extension)
wants_telemetry = profile_wants_telemetry(profile)
base_dir = extension.config.get("base_dir", "/tmp/sandboxer")
destroy_before = None
if wants_telemetry and status.host:
if wants_telemetry and status.host and extension.capabilities.pricing_model != "metered":
destroy_before = collect_host_snapshot(status.host)
status.state = SandboxState.DESTROYING
@ -139,6 +168,7 @@ class SandboxManager:
"ssh_port": status.inputs.get("ssh_port", ""),
"vm_target": status.inputs.get("vm_target", ""),
"vm_host": status.inputs.get("vm_host", ""),
"endpoint": status.inputs.get("endpoint", ""),
}
backend.teardown(handle)
@ -146,6 +176,19 @@ class SandboxManager:
status.destroyed_at = utcnow()
status.updated_at = status.destroyed_at
settled = settle_usage(status, extension, handle, destroyed_at=status.destroyed_at)
if settled and settled.pricing_model == "metered" and settled.actual_usd:
self.credits.debit(settled.actual_usd)
status.meter = settled
emit_lifecycle_event(
status,
summary=(
f"Sandbox metered: {settled.actual_usd:.4f} USD "
f"({settled.duration_s:.0f}s, ext={extension.id})"
),
event_type="note",
)
if wants_telemetry and destroy_before and status.host:
destroy_after = collect_host_snapshot(status.host)
report = build_introspection_report(

View file

@ -6,7 +6,7 @@ import uuid
from abc import ABC, abstractmethod
from typing import Any
from sandboxer.models import Profile
from sandboxer.models import MeterQuote, Profile
class SandboxExtension(ABC):
@ -31,4 +31,18 @@ class SandboxExtension(ABC):
@abstractmethod
def teardown(self, handle: dict[str, str]) -> dict[str, str]:
"""Release sandbox resources. Returns cleanup report fields."""
"""Release sandbox resources. Returns cleanup report fields."""
def estimate_cost(
self,
profile: Profile,
inputs: dict[str, str],
*,
duration_s: int = 3600,
) -> MeterQuote | None:
"""Optional pre-create cost quote (metered SaaS extensions)."""
return None
def meter_actual(self, handle: dict[str, str], *, duration_s: float) -> float | None:
"""Optional post-destroy actual cost in USD."""
return None

View file

@ -0,0 +1,66 @@
"""ext.saas-stub — metered SaaS extension stub for routing and payments v0.
No external provider API. Exercises estimate_cost, credits debit, and routing
fallback without E2B/Modal credentials.
"""
from __future__ import annotations
from typing import Any
from sandboxer.extensions.base import SandboxExtension
from sandboxer.models import MeterQuote, Profile
class SaaSStubExtension(SandboxExtension):
"""Simulated metered SaaS sandbox backend."""
def __init__(self, config: dict[str, Any] | None = None) -> None:
super().__init__(config)
self.rate_usd_per_hour: float = float(self.config.get("rate_usd_per_hour", 0.12))
self.session_fee_usd: float = float(self.config.get("session_fee_usd", 0.01))
self.provider: str = self.config.get("provider", "saas-stub")
def estimate_cost(
self,
profile: Profile,
inputs: dict[str, str],
*,
duration_s: int = 3600,
) -> MeterQuote:
hours = max(duration_s / 3600.0, 1 / 3600)
estimated = round(self.session_fee_usd + hours * self.rate_usd_per_hour, 4)
return MeterQuote(
extension_id="ext.saas-stub",
estimated_usd=estimated,
unit="per_hour",
duration_s=duration_s,
)
def meter_actual(self, handle: dict[str, str], *, duration_s: float) -> float:
hours = max(duration_s / 3600.0, 1 / 3600)
return round(self.session_fee_usd + hours * self.rate_usd_per_hour, 4)
def provision(
self, profile: Profile, inputs: dict[str, str], host: str
) -> dict[str, str]:
sandbox_id = self.new_sandbox_id(inputs)
endpoint = f"https://stub.sandboxer.local/{sandbox_id}"
return {
"sandbox_id": sandbox_id,
"host": self.provider,
"endpoint": endpoint,
"provider": self.provider,
}
def wait_ready(self, handle: dict[str, str]) -> dict[str, str]:
return {
"endpoint": handle["endpoint"],
"host": handle.get("host"),
}
def teardown(self, handle: dict[str, str]) -> dict[str, str]:
return {
"provider_removed": "true",
"sandbox_id": handle.get("sandbox_id", ""),
}

View file

@ -86,10 +86,32 @@ class ProfileMetadata(BaseModel):
observability: Literal["none", "canary"] = "none"
class RouteSpec(BaseModel):
strategy: RouteStrategy = RouteStrategy.EXPLICIT
extensions: list[str] = Field(default_factory=list)
max_cost_per_hour_usd: float | None = None
class MeterQuote(BaseModel):
extension_id: str
estimated_usd: float
unit: Literal["per_hour", "per_session"] = "per_hour"
duration_s: int = 3600
class MeterRecord(BaseModel):
pricing_model: Literal["self-hosted", "metered"] = "self-hosted"
estimate_usd: float | None = None
actual_usd: float | None = None
duration_s: float | None = None
currency: str = "USD"
class Profile(BaseModel):
id: str
version: str
extension: str
route: RouteSpec | None = None
isolation: IsolationSpec = Field(default_factory=IsolationSpec)
network: NetworkSpec = Field(default_factory=NetworkSpec)
workspace: WorkspaceSpec = Field(default_factory=WorkspaceSpec)
@ -130,6 +152,7 @@ class Reachability(BaseModel):
remote_dir: str | None = None
compose_project: str | None = None
host: str | None = None
endpoint: str | None = None
class SandboxStatus(BaseModel):
@ -142,6 +165,7 @@ class SandboxStatus(BaseModel):
reachability: Reachability | None = None
inputs: dict[str, str] = Field(default_factory=dict)
error: str | None = None
meter: MeterRecord | None = None
telemetry: dict | None = None # IntrospectionReport JSON when canary
created_at: datetime
updated_at: datetime

View 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"]

View 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

View 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),
)

View file

@ -0,0 +1,5 @@
"""Extension routing — OpenRouter-style backend selection."""
from sandboxer.routing.resolver import resolve_extension
__all__ = ["resolve_extension"]

View file

@ -0,0 +1,97 @@
"""Select extension backend from profile route policy."""
from __future__ import annotations
import os
from sandboxer.extensions.registry import load_extension
from sandboxer.models import Extension, Profile, RouteStrategy
from sandboxer.payments.metering import estimate_cost
from sandboxer.placement import resolve_host
def _candidates(profile: Profile) -> list[str]:
if profile.route and profile.route.extensions:
return profile.route.extensions
return [profile.extension]
def _is_metered(ext: Extension) -> bool:
return ext.capabilities.pricing_model == "metered"
def _self_hosted_available(profile: Profile, ext: Extension, host_override: str | None) -> bool:
if _is_metered(ext):
return True
if os.environ.get("SANDBOXER_FORCE_SAAS") == "1":
return False
try:
resolve_host(profile, override=host_override)
return True
except ValueError:
return False
def _quote_cost(
ext: Extension, profile: Profile, inputs: dict[str, str], duration_s: int
) -> float | None:
quote = estimate_cost(ext, profile, inputs, duration_s=duration_s)
return quote.estimated_usd if quote else None
def resolve_extension(
profile: Profile,
inputs: dict[str, str],
*,
host_override: str | None = None,
duration_s: int = 3600,
) -> Extension:
"""Pick extension per route strategy. Raises if no candidate qualifies."""
strategy = (
profile.route.strategy
if profile.route
else RouteStrategy.EXPLICIT
)
ids = _candidates(profile)
loaded = [load_extension(ext_id) for ext_id in ids]
if strategy == RouteStrategy.EXPLICIT or len(loaded) == 1:
chosen = load_extension(profile.extension)
if chosen.id not in {e.id for e in loaded}:
chosen = loaded[0]
return chosen
if strategy == RouteStrategy.PREFER_SELF_HOSTED:
for ext in loaded:
if not _is_metered(ext) and _self_hosted_available(profile, ext, host_override):
return ext
for ext in loaded:
if _is_metered(ext):
return ext
return loaded[0]
if strategy == RouteStrategy.LOWEST_COST:
best: Extension | None = None
best_cost: float | None = None
for ext in loaded:
if not _is_metered(ext) and _self_hosted_available(profile, ext, host_override):
return ext
cost = _quote_cost(ext, profile, inputs, duration_s)
if cost is None:
continue
max_hour = profile.route.max_cost_per_hour_usd if profile.route else None
if max_hour is not None and cost > max_hour:
continue
if best is None or cost < (best_cost or float("inf")):
best, best_cost = ext, cost
if best:
return best
return loaded[-1]
if strategy == RouteStrategy.LOWEST_LATENCY:
for ext in loaded:
if not _is_metered(ext) and _self_hosted_available(profile, ext, host_override):
return ext
return loaded[-1]
return load_extension(profile.extension)