feat: cloud adapters E2B/Modal and billing export (SAND-WP-0010)

Add credentialed E2B and Modal extensions, burst routing fallback,
fin-hub meter export hook, BYOK docs, and 77 tests.
This commit is contained in:
tegwick 2026-06-24 12:50:19 +02:00
parent 6d0a1a8b1e
commit 15f031fd65
26 changed files with 859 additions and 75 deletions

View file

@ -21,6 +21,7 @@ from sandboxer.models import (
SandboxStatus,
SnapshotRecord,
)
from sandboxer.payments.billing_export import export_meter_usage
from sandboxer.payments.credits import CreditsStore
from sandboxer.payments.metering import estimate_cost, settle_usage
from sandboxer.placement import resolve_host
@ -60,6 +61,8 @@ class SandboxManager:
"vm_target": status.inputs.get("vm_target", ""),
"vm_host": status.inputs.get("vm_host", ""),
"endpoint": status.inputs.get("endpoint", ""),
"provider_sandbox_id": status.inputs.get("provider_sandbox_id", ""),
"provider": status.inputs.get("provider", ""),
}
def _resolved_host(self, profile, extension, host_override: str | None) -> str:
@ -133,6 +136,8 @@ class SandboxManager:
status.inputs["vm_target"] = handle.get("vm_target", "")
status.inputs["vm_host"] = handle.get("vm_host", "")
status.inputs["endpoint"] = handle.get("endpoint", "")
status.inputs["provider_sandbox_id"] = handle.get("provider_sandbox_id", "")
status.inputs["provider"] = handle.get("provider", "")
reach = backend.wait_ready(handle)
status.reachability = Reachability(**reach)
status.state = SandboxState.READY
@ -209,6 +214,7 @@ class SandboxManager:
if settled and settled.pricing_model == "metered" and settled.actual_usd:
self.credits.debit(settled.actual_usd)
status.meter = settled
export_meter_usage(status, extension_id=extension.id, meter=settled)
emit_lifecycle_event(
status,
summary=(

View file

@ -0,0 +1,83 @@
"""Shared helpers for metered HTTP cloud sandbox adapters."""
from __future__ import annotations
from typing import Any, Protocol
import httpx
from sandboxer.extensions.base import SandboxExtension
from sandboxer.extensions.credentials import resolve_api_key
from sandboxer.models import MeterQuote, Profile
class HttpClientFactory(Protocol):
def __call__(self) -> httpx.Client: ...
def default_http_client() -> httpx.Client:
return httpx.Client(timeout=60.0)
class CloudMeteredExtension(SandboxExtension):
"""Base for E2B/Modal-style REST sandbox providers."""
extension_id: str = ""
def __init__(
self,
config: dict[str, Any] | None = None,
*,
client_factory: HttpClientFactory | None = None,
) -> None:
super().__init__(config)
self.api_base: str = str(self.config.get("api_base", "")).rstrip("/")
self.api_key_env: str = str(self.config.get("api_key_env", ""))
self.provider: str = self.config.get("provider", self.extension_id)
self.rate_usd_per_hour: float = float(self.config.get("rate_usd_per_hour", 0.15))
self.session_fee_usd: float = float(self.config.get("session_fee_usd", 0.02))
self._client_factory = client_factory or default_http_client
@classmethod
def credentials_available(cls, config: dict[str, Any]) -> bool:
from sandboxer.extensions.credentials import credentials_available as _avail
return _avail(cls.extension_id, config)
def _api_key(self) -> str:
key = resolve_api_key(self.config, extension_id=self.extension_id)
if not key:
raise RuntimeError(
f"{self.extension_id}: API key not configured "
f"(set {self.api_key_env} or secret_ref env mapping)"
)
return key
def _headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self._api_key()}",
"Content-Type": "application/json",
}
def _client(self) -> httpx.Client:
return self._client_factory()
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=self.extension_id,
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)

View file

@ -0,0 +1,48 @@
"""BYOK credential resolution for metered cloud extensions."""
from __future__ import annotations
import os
from typing import Any
def _secret_ref_env(secret_ref: str) -> str:
normalized = secret_ref.upper().replace("-", "_").replace(".", "_")
return f"SANDBOXER_SECRET_{normalized}"
def resolve_api_key(
config: dict[str, Any],
*,
extension_id: str,
) -> str | None:
"""Resolve provider API key from env or secret_ref mapping (never from Git)."""
env_name = config.get("api_key_env")
if env_name:
value = os.environ.get(env_name)
if value:
return value
fallback_env = (
f"SANDBOXER_{extension_id.upper().replace('.', '_').replace('-', '_')}_API_KEY"
)
value = os.environ.get(fallback_env)
if value:
return value
secret_ref = config.get("secret_ref")
if secret_ref:
return os.environ.get(_secret_ref_env(secret_ref))
return None
def credentials_available(
extension_id: str,
config: dict[str, Any],
*,
always_available: bool = False,
) -> bool:
if always_available:
return True
return resolve_api_key(config, extension_id=extension_id) is not None

View file

@ -0,0 +1,77 @@
"""ext.e2b — E2B cloud sandbox adapter."""
from __future__ import annotations
from typing import Any
from sandboxer.extensions.cloud_base import CloudMeteredExtension, default_http_client
from sandboxer.models import Profile
# Re-export for tests
http_client_factory = default_http_client
class E2BExtension(CloudMeteredExtension):
extension_id = "ext.e2b"
def __init__(self, config: dict[str, Any] | None = None, **kwargs) -> None:
super().__init__(config, **kwargs)
self.template_id: str = self.config.get("template_id", "base")
def provision(
self, profile: Profile, inputs: dict[str, str], host: str
) -> dict[str, str]:
sandbox_id = self.new_sandbox_id(inputs)
template = inputs.get("template") or self.template_id
payload = {"templateID": template, "metadata": {"sandboxer_id": sandbox_id}}
with self._client() as client:
response = client.post(
f"{self.api_base}/sandboxes",
json=payload,
headers=self._headers(),
)
if response.status_code >= 400:
raise RuntimeError(f"E2B provision failed: {response.text}")
data = response.json()
provider_sandbox_id = data.get("sandboxID") or data.get("sandbox_id", "")
endpoint = data.get("sandboxURL") or f"https://{provider_sandbox_id}.e2b.dev"
return {
"sandbox_id": sandbox_id,
"provider_sandbox_id": provider_sandbox_id,
"host": self.provider,
"endpoint": endpoint,
"provider": self.provider,
"template_id": template,
}
def wait_ready(self, handle: dict[str, str]) -> dict[str, str]:
provider_id = handle.get("provider_sandbox_id", "")
with self._client() as client:
response = client.get(
f"{self.api_base}/sandboxes/{provider_id}",
headers=self._headers(),
)
if response.status_code >= 400:
raise RuntimeError(f"E2B wait_ready failed: {response.text}")
return {
"endpoint": handle["endpoint"],
"host": handle.get("host"),
}
def teardown(self, handle: dict[str, str]) -> dict[str, str]:
provider_id = handle.get("provider_sandbox_id", "")
removed = False
if provider_id:
with self._client() as client:
response = client.delete(
f"{self.api_base}/sandboxes/{provider_id}",
headers=self._headers(),
)
removed = response.status_code < 400
return {
"provider_removed": str(removed).lower(),
"sandbox_id": handle.get("sandbox_id", ""),
"provider_sandbox_id": provider_id,
}

View file

@ -0,0 +1,83 @@
"""ext.modal — Modal cloud sandbox adapter."""
from __future__ import annotations
from typing import Any
from sandboxer.extensions.cloud_base import CloudMeteredExtension, default_http_client
from sandboxer.models import Profile
http_client_factory = default_http_client
class ModalExtension(CloudMeteredExtension):
extension_id = "ext.modal"
def __init__(self, config: dict[str, Any] | None = None, **kwargs) -> None:
super().__init__(config, **kwargs)
self.image_ref: str = self.config.get("image_ref", "modal-default")
def provision(
self, profile: Profile, inputs: dict[str, str], host: str
) -> dict[str, str]:
sandbox_id = self.new_sandbox_id(inputs)
image = inputs.get("image") or self.image_ref
payload = {
"image": image,
"metadata": {"sandboxer_id": sandbox_id},
}
with self._client() as client:
response = client.post(
f"{self.api_base}/v1/sandboxes",
json=payload,
headers=self._headers(),
)
if response.status_code >= 400:
raise RuntimeError(f"Modal provision failed: {response.text}")
data = response.json()
provider_sandbox_id = data.get("sandbox_id") or data.get("id", "")
endpoint = data.get("url") or f"https://modal.run/sandbox/{provider_sandbox_id}"
return {
"sandbox_id": sandbox_id,
"provider_sandbox_id": provider_sandbox_id,
"host": self.provider,
"endpoint": endpoint,
"provider": self.provider,
"image_ref": image,
}
def wait_ready(self, handle: dict[str, str]) -> dict[str, str]:
provider_id = handle.get("provider_sandbox_id", "")
with self._client() as client:
response = client.get(
f"{self.api_base}/v1/sandboxes/{provider_id}",
headers=self._headers(),
)
if response.status_code >= 400:
raise RuntimeError(f"Modal wait_ready failed: {response.text}")
data = response.json()
state = data.get("status", "ready")
if state not in ("ready", "running"):
raise RuntimeError(f"Modal sandbox not ready: {state}")
return {
"endpoint": handle["endpoint"],
"host": handle.get("host"),
}
def teardown(self, handle: dict[str, str]) -> dict[str, str]:
provider_id = handle.get("provider_sandbox_id", "")
removed = False
if provider_id:
with self._client() as client:
response = client.delete(
f"{self.api_base}/v1/sandboxes/{provider_id}",
headers=self._headers(),
)
removed = response.status_code < 400
return {
"provider_removed": str(removed).lower(),
"sandbox_id": handle.get("sandbox_id", ""),
"provider_sandbox_id": provider_id,
}

View file

@ -0,0 +1,49 @@
"""Optional fin-hub billing export for metered sandbox usage."""
from __future__ import annotations
import os
from typing import Any
import httpx
from sandboxer.models import MeterRecord, SandboxStatus
def fin_hub_url() -> str | None:
return os.environ.get("SANDBOXER_FIN_HUB_URL") or None
def export_meter_usage(
status: SandboxStatus,
*,
extension_id: str,
meter: MeterRecord,
) -> dict[str, Any] | None:
"""POST usage record to fin-hub when SANDBOXER_FIN_HUB_URL is set."""
if os.environ.get("SANDBOXER_NO_FIN_HUB", "").lower() in ("1", "true", "yes"):
return None
if meter.pricing_model != "metered" or not meter.actual_usd:
return None
base = fin_hub_url()
if not base:
return None
payload = {
"sandbox_id": status.sandbox_id,
"extension_id": extension_id,
"profile_id": status.profile_id,
"consumer": status.consumer.model_dump(),
"duration_s": meter.duration_s,
"actual_usd": meter.actual_usd,
"estimate_usd": meter.estimate_usd,
"currency": meter.currency,
}
try:
response = httpx.post(f"{base.rstrip('/')}/usage/sandbox", json=payload, timeout=10.0)
response.raise_for_status()
return response.json()
except httpx.HTTPError:
return None

View file

@ -4,6 +4,7 @@ from __future__ import annotations
import os
from sandboxer.extensions.credentials import credentials_available
from sandboxer.extensions.registry import load_extension
from sandboxer.models import Extension, Profile, RouteStrategy
from sandboxer.payments.metering import estimate_cost
@ -20,6 +21,49 @@ def _is_metered(ext: Extension) -> bool:
return ext.capabilities.pricing_model == "metered"
def _metered_available(ext: Extension) -> bool:
if ext.id == "ext.saas-stub":
return True
return credentials_available(ext.id, ext.config)
def _select_metered_fallback(
loaded: list[Extension],
profile: Profile,
inputs: dict[str, str],
*,
duration_s: int,
) -> Extension | None:
"""Pick cheapest credentialed metered extension; stub is always last resort."""
available = [ext for ext in loaded if _is_metered(ext) and _metered_available(ext)]
if not available:
return None
strategy = profile.route.strategy if profile.route else RouteStrategy.EXPLICIT
if strategy == RouteStrategy.LOWEST_COST:
best: Extension | None = None
best_cost: float | None = None
for ext in available:
if not _metered_available(ext):
continue
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
for ext_id in ("ext.e2b", "ext.modal", "ext.saas-stub"):
for ext in available:
if ext.id == ext_id:
return ext
return available[0]
def _self_hosted_available(profile: Profile, ext: Extension, host_override: str | None) -> bool:
if _is_metered(ext):
return True
@ -65,6 +109,11 @@ def resolve_extension(
for ext in loaded:
if not _is_metered(ext) and _self_hosted_available(profile, ext, host_override):
return ext
fallback = _select_metered_fallback(
loaded, profile, inputs, duration_s=duration_s
)
if fallback:
return fallback
for ext in loaded:
if _is_metered(ext):
return ext
@ -76,6 +125,8 @@ def resolve_extension(
for ext in loaded:
if not _is_metered(ext) and _self_hosted_available(profile, ext, host_override):
return ext
if not _metered_available(ext):
continue
cost = _quote_cost(ext, profile, inputs, duration_s)
if cost is None:
continue
@ -92,6 +143,11 @@ def resolve_extension(
for ext in loaded:
if not _is_metered(ext) and _self_hosted_available(profile, ext, host_override):
return ext
fallback = _select_metered_fallback(
loaded, profile, inputs, duration_s=duration_s
)
if fallback:
return fallback
return loaded[-1]
return load_extension(profile.extension)