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

@ -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,
}