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:
parent
6d0a1a8b1e
commit
15f031fd65
26 changed files with 859 additions and 75 deletions
|
|
@ -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=(
|
||||
|
|
|
|||
83
src/sandboxer/extensions/cloud_base.py
Normal file
83
src/sandboxer/extensions/cloud_base.py
Normal 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)
|
||||
48
src/sandboxer/extensions/credentials.py
Normal file
48
src/sandboxer/extensions/credentials.py
Normal 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
|
||||
77
src/sandboxer/extensions/e2b.py
Normal file
77
src/sandboxer/extensions/e2b.py
Normal 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,
|
||||
}
|
||||
83
src/sandboxer/extensions/modal.py
Normal file
83
src/sandboxer/extensions/modal.py
Normal 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,
|
||||
}
|
||||
49
src/sandboxer/payments/billing_export.py
Normal file
49
src/sandboxer/payments/billing_export.py
Normal 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
|
||||
|
|
@ -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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue