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

@ -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)