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
5
src/sandboxer/routing/__init__.py
Normal file
5
src/sandboxer/routing/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""Extension routing — OpenRouter-style backend selection."""
|
||||
|
||||
from sandboxer.routing.resolver import resolve_extension
|
||||
|
||||
__all__ = ["resolve_extension"]
|
||||
97
src/sandboxer/routing/resolver.py
Normal file
97
src/sandboxer/routing/resolver.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue