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
|
|
@ -11,6 +11,7 @@ from sandboxer import __version__
|
|||
from sandboxer.core.manager import SandboxManager
|
||||
from sandboxer.defaults import resolve_create_defaults
|
||||
from sandboxer.models import ActorType, Consumer, SandboxCreateRequest
|
||||
from sandboxer.payments.credits import CreditsStore
|
||||
from sandboxer.placement import resolve_host
|
||||
from sandboxer.profiles.loader import load_profile
|
||||
from sandboxer.telemetry.export import export_telemetry
|
||||
|
|
@ -25,6 +26,8 @@ app = typer.Typer(
|
|||
)
|
||||
inspect_app = typer.Typer(help="Host introspection without provisioning.")
|
||||
app.add_typer(inspect_app, name="inspect")
|
||||
credits_app = typer.Typer(help="SaaS sandbox credits (metered extensions).")
|
||||
app.add_typer(credits_app, name="credits")
|
||||
|
||||
|
||||
@app.callback()
|
||||
|
|
@ -216,5 +219,22 @@ def reap_stale_cmd(
|
|||
_print_json([r.model_dump(mode="json") for r in results])
|
||||
|
||||
|
||||
@credits_app.command("show")
|
||||
def credits_show() -> None:
|
||||
"""Show current credit balance."""
|
||||
store = CreditsStore()
|
||||
_print_json({"balance_usd": store.balance(), "currency": "USD"})
|
||||
|
||||
|
||||
@credits_app.command("add")
|
||||
def credits_add(
|
||||
amount: Annotated[float, typer.Argument(help="USD amount to add")],
|
||||
) -> None:
|
||||
"""Add credits to the workspace balance."""
|
||||
store = CreditsStore()
|
||||
new_balance = store.add(amount)
|
||||
typer.echo(f"Balance: {new_balance:.4f} USD")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
|
|
@ -6,13 +6,17 @@ from sandboxer.extensions.registry import load_extension, resolve_backend
|
|||
from sandboxer.lifecycle.state_hub import emit_lifecycle_event, event_type_for_state
|
||||
from sandboxer.lifecycle.store import SandboxStore, utcnow
|
||||
from sandboxer.models import (
|
||||
MeterRecord,
|
||||
Reachability,
|
||||
SandboxCreateRequest,
|
||||
SandboxState,
|
||||
SandboxStatus,
|
||||
)
|
||||
from sandboxer.payments.credits import CreditsStore
|
||||
from sandboxer.payments.metering import estimate_cost, settle_usage
|
||||
from sandboxer.placement import resolve_host
|
||||
from sandboxer.profiles.loader import load_profile
|
||||
from sandboxer.routing.resolver import resolve_extension
|
||||
from sandboxer.telemetry.export import export_telemetry
|
||||
from sandboxer.telemetry.introspection import (
|
||||
build_introspection_report,
|
||||
|
|
@ -22,17 +26,40 @@ from sandboxer.telemetry.introspection import (
|
|||
|
||||
|
||||
class SandboxManager:
|
||||
def __init__(self, store: SandboxStore | None = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
store: SandboxStore | None = None,
|
||||
credits: CreditsStore | None = None,
|
||||
) -> None:
|
||||
self.store = store or SandboxStore()
|
||||
self.credits = credits or CreditsStore()
|
||||
|
||||
def _resolved_host(self, profile, extension, host_override: str | None) -> str:
|
||||
if extension.capabilities.pricing_model == "metered":
|
||||
return extension.config.get("provider", "saas")
|
||||
return resolve_host(profile, override=host_override)
|
||||
|
||||
def create(self, request: SandboxCreateRequest, *, host: str | None = None) -> SandboxStatus:
|
||||
profile = load_profile(request.profile)
|
||||
extension = load_extension(profile.extension)
|
||||
extension = resolve_extension(profile, request.inputs, host_override=host)
|
||||
backend = resolve_backend(extension)
|
||||
resolved_host = resolve_host(profile, override=host)
|
||||
resolved_host = self._resolved_host(profile, extension, host)
|
||||
wants_telemetry = profile_wants_telemetry(profile)
|
||||
base_dir = extension.config.get("base_dir", "/tmp/sandboxer")
|
||||
|
||||
quote = estimate_cost(extension, profile, request.inputs)
|
||||
meter_record: MeterRecord | None = None
|
||||
if quote:
|
||||
if not self.credits.can_afford(quote.estimated_usd):
|
||||
raise RuntimeError(
|
||||
f"Insufficient credits: need {quote.estimated_usd:.4f} USD, "
|
||||
f"balance {self.credits.balance():.4f} USD"
|
||||
)
|
||||
meter_record = MeterRecord(
|
||||
pricing_model="metered",
|
||||
estimate_usd=quote.estimated_usd,
|
||||
)
|
||||
|
||||
now = utcnow()
|
||||
status = SandboxStatus(
|
||||
sandbox_id="pending",
|
||||
|
|
@ -42,6 +69,7 @@ class SandboxManager:
|
|||
consumer=request.consumer,
|
||||
host=resolved_host,
|
||||
inputs=dict(request.inputs),
|
||||
meter=meter_record,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
|
@ -52,7 +80,7 @@ class SandboxManager:
|
|||
emit_lifecycle_event(status, event_type=event_type_for_state(status.state))
|
||||
|
||||
provision_before = None
|
||||
if wants_telemetry:
|
||||
if wants_telemetry and extension.capabilities.pricing_model != "metered":
|
||||
provision_before = collect_host_snapshot(resolved_host)
|
||||
|
||||
try:
|
||||
|
|
@ -64,6 +92,7 @@ class SandboxManager:
|
|||
status.inputs["ssh_port"] = handle.get("ssh_port", "")
|
||||
status.inputs["vm_target"] = handle.get("vm_target", "")
|
||||
status.inputs["vm_host"] = handle.get("vm_host", "")
|
||||
status.inputs["endpoint"] = handle.get("endpoint", "")
|
||||
reach = backend.wait_ready(handle)
|
||||
status.reachability = Reachability(**reach)
|
||||
status.state = SandboxState.READY
|
||||
|
|
@ -114,13 +143,13 @@ class SandboxManager:
|
|||
return status
|
||||
|
||||
profile = load_profile(status.profile_id)
|
||||
extension = load_extension(profile.extension)
|
||||
extension = load_extension(status.extension_id)
|
||||
backend = resolve_backend(extension)
|
||||
wants_telemetry = profile_wants_telemetry(profile)
|
||||
base_dir = extension.config.get("base_dir", "/tmp/sandboxer")
|
||||
|
||||
destroy_before = None
|
||||
if wants_telemetry and status.host:
|
||||
if wants_telemetry and status.host and extension.capabilities.pricing_model != "metered":
|
||||
destroy_before = collect_host_snapshot(status.host)
|
||||
|
||||
status.state = SandboxState.DESTROYING
|
||||
|
|
@ -139,6 +168,7 @@ class SandboxManager:
|
|||
"ssh_port": status.inputs.get("ssh_port", ""),
|
||||
"vm_target": status.inputs.get("vm_target", ""),
|
||||
"vm_host": status.inputs.get("vm_host", ""),
|
||||
"endpoint": status.inputs.get("endpoint", ""),
|
||||
}
|
||||
backend.teardown(handle)
|
||||
|
||||
|
|
@ -146,6 +176,19 @@ class SandboxManager:
|
|||
status.destroyed_at = utcnow()
|
||||
status.updated_at = status.destroyed_at
|
||||
|
||||
settled = settle_usage(status, extension, handle, destroyed_at=status.destroyed_at)
|
||||
if settled and settled.pricing_model == "metered" and settled.actual_usd:
|
||||
self.credits.debit(settled.actual_usd)
|
||||
status.meter = settled
|
||||
emit_lifecycle_event(
|
||||
status,
|
||||
summary=(
|
||||
f"Sandbox metered: {settled.actual_usd:.4f} USD "
|
||||
f"({settled.duration_s:.0f}s, ext={extension.id})"
|
||||
),
|
||||
event_type="note",
|
||||
)
|
||||
|
||||
if wants_telemetry and destroy_before and status.host:
|
||||
destroy_after = collect_host_snapshot(status.host)
|
||||
report = build_introspection_report(
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import uuid
|
|||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
from sandboxer.models import Profile
|
||||
from sandboxer.models import MeterQuote, Profile
|
||||
|
||||
|
||||
class SandboxExtension(ABC):
|
||||
|
|
@ -31,4 +31,18 @@ class SandboxExtension(ABC):
|
|||
|
||||
@abstractmethod
|
||||
def teardown(self, handle: dict[str, str]) -> dict[str, str]:
|
||||
"""Release sandbox resources. Returns cleanup report fields."""
|
||||
"""Release sandbox resources. Returns cleanup report fields."""
|
||||
|
||||
def estimate_cost(
|
||||
self,
|
||||
profile: Profile,
|
||||
inputs: dict[str, str],
|
||||
*,
|
||||
duration_s: int = 3600,
|
||||
) -> MeterQuote | None:
|
||||
"""Optional pre-create cost quote (metered SaaS extensions)."""
|
||||
return None
|
||||
|
||||
def meter_actual(self, handle: dict[str, str], *, duration_s: float) -> float | None:
|
||||
"""Optional post-destroy actual cost in USD."""
|
||||
return None
|
||||
66
src/sandboxer/extensions/saas_stub.py
Normal file
66
src/sandboxer/extensions/saas_stub.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
"""ext.saas-stub — metered SaaS extension stub for routing and payments v0.
|
||||
|
||||
No external provider API. Exercises estimate_cost, credits debit, and routing
|
||||
fallback without E2B/Modal credentials.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sandboxer.extensions.base import SandboxExtension
|
||||
from sandboxer.models import MeterQuote, Profile
|
||||
|
||||
|
||||
class SaaSStubExtension(SandboxExtension):
|
||||
"""Simulated metered SaaS sandbox backend."""
|
||||
|
||||
def __init__(self, config: dict[str, Any] | None = None) -> None:
|
||||
super().__init__(config)
|
||||
self.rate_usd_per_hour: float = float(self.config.get("rate_usd_per_hour", 0.12))
|
||||
self.session_fee_usd: float = float(self.config.get("session_fee_usd", 0.01))
|
||||
self.provider: str = self.config.get("provider", "saas-stub")
|
||||
|
||||
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="ext.saas-stub",
|
||||
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)
|
||||
|
||||
def provision(
|
||||
self, profile: Profile, inputs: dict[str, str], host: str
|
||||
) -> dict[str, str]:
|
||||
sandbox_id = self.new_sandbox_id(inputs)
|
||||
endpoint = f"https://stub.sandboxer.local/{sandbox_id}"
|
||||
return {
|
||||
"sandbox_id": sandbox_id,
|
||||
"host": self.provider,
|
||||
"endpoint": endpoint,
|
||||
"provider": self.provider,
|
||||
}
|
||||
|
||||
def wait_ready(self, handle: dict[str, str]) -> dict[str, str]:
|
||||
return {
|
||||
"endpoint": handle["endpoint"],
|
||||
"host": handle.get("host"),
|
||||
}
|
||||
|
||||
def teardown(self, handle: dict[str, str]) -> dict[str, str]:
|
||||
return {
|
||||
"provider_removed": "true",
|
||||
"sandbox_id": handle.get("sandbox_id", ""),
|
||||
}
|
||||
|
|
@ -86,10 +86,32 @@ class ProfileMetadata(BaseModel):
|
|||
observability: Literal["none", "canary"] = "none"
|
||||
|
||||
|
||||
class RouteSpec(BaseModel):
|
||||
strategy: RouteStrategy = RouteStrategy.EXPLICIT
|
||||
extensions: list[str] = Field(default_factory=list)
|
||||
max_cost_per_hour_usd: float | None = None
|
||||
|
||||
|
||||
class MeterQuote(BaseModel):
|
||||
extension_id: str
|
||||
estimated_usd: float
|
||||
unit: Literal["per_hour", "per_session"] = "per_hour"
|
||||
duration_s: int = 3600
|
||||
|
||||
|
||||
class MeterRecord(BaseModel):
|
||||
pricing_model: Literal["self-hosted", "metered"] = "self-hosted"
|
||||
estimate_usd: float | None = None
|
||||
actual_usd: float | None = None
|
||||
duration_s: float | None = None
|
||||
currency: str = "USD"
|
||||
|
||||
|
||||
class Profile(BaseModel):
|
||||
id: str
|
||||
version: str
|
||||
extension: str
|
||||
route: RouteSpec | None = None
|
||||
isolation: IsolationSpec = Field(default_factory=IsolationSpec)
|
||||
network: NetworkSpec = Field(default_factory=NetworkSpec)
|
||||
workspace: WorkspaceSpec = Field(default_factory=WorkspaceSpec)
|
||||
|
|
@ -130,6 +152,7 @@ class Reachability(BaseModel):
|
|||
remote_dir: str | None = None
|
||||
compose_project: str | None = None
|
||||
host: str | None = None
|
||||
endpoint: str | None = None
|
||||
|
||||
|
||||
class SandboxStatus(BaseModel):
|
||||
|
|
@ -142,6 +165,7 @@ class SandboxStatus(BaseModel):
|
|||
reachability: Reachability | None = None
|
||||
inputs: dict[str, str] = Field(default_factory=dict)
|
||||
error: str | None = None
|
||||
meter: MeterRecord | None = None
|
||||
telemetry: dict | None = None # IntrospectionReport JSON when canary
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
|
|
|||
6
src/sandboxer/payments/__init__.py
Normal file
6
src/sandboxer/payments/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
"""Payments and metering for SaaS sandbox extensions."""
|
||||
|
||||
from sandboxer.payments.credits import CreditsStore
|
||||
from sandboxer.payments.metering import estimate_cost, settle_usage
|
||||
|
||||
__all__ = ["CreditsStore", "estimate_cost", "settle_usage"]
|
||||
48
src/sandboxer/payments/credits.py
Normal file
48
src/sandboxer/payments/credits.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"""Org/workspace credits for metered sandbox consumption."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _default_credits_path() -> Path:
|
||||
base = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
|
||||
return base / "sandboxer" / "credits.json"
|
||||
|
||||
|
||||
class CreditsStore:
|
||||
def __init__(self, path: Path | None = None) -> None:
|
||||
self.path = path or _default_credits_path()
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _read(self) -> dict:
|
||||
if not self.path.exists():
|
||||
default = float(os.environ.get("SANDBOXER_DEFAULT_CREDITS", "10.0"))
|
||||
return {"balance_usd": default, "currency": "USD"}
|
||||
return json.loads(self.path.read_text())
|
||||
|
||||
def _write(self, data: dict) -> None:
|
||||
self.path.write_text(json.dumps(data, indent=2))
|
||||
|
||||
def balance(self) -> float:
|
||||
return float(self._read().get("balance_usd", 0.0))
|
||||
|
||||
def can_afford(self, amount_usd: float) -> bool:
|
||||
return self.balance() >= amount_usd
|
||||
|
||||
def add(self, amount_usd: float) -> float:
|
||||
data = self._read()
|
||||
data["balance_usd"] = round(float(data.get("balance_usd", 0.0)) + amount_usd, 4)
|
||||
self._write(data)
|
||||
return data["balance_usd"]
|
||||
|
||||
def debit(self, amount_usd: float) -> float:
|
||||
data = self._read()
|
||||
new_balance = round(float(data.get("balance_usd", 0.0)) - amount_usd, 4)
|
||||
if new_balance < 0:
|
||||
raise ValueError(f"Insufficient credits: need {amount_usd:.4f} USD")
|
||||
data["balance_usd"] = new_balance
|
||||
self._write(data)
|
||||
return new_balance
|
||||
66
src/sandboxer/payments/metering.py
Normal file
66
src/sandboxer/payments/metering.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
"""Cost estimation and usage settlement for metered extensions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sandboxer.extensions.registry import resolve_backend
|
||||
from sandboxer.models import Extension, MeterQuote, MeterRecord, Profile, SandboxStatus
|
||||
|
||||
|
||||
def _duration_seconds(ready_at: datetime | None, destroyed_at: datetime) -> float:
|
||||
if not ready_at:
|
||||
return 0.0
|
||||
return max(0.0, (destroyed_at - ready_at).total_seconds())
|
||||
|
||||
|
||||
def estimate_cost(
|
||||
extension: Extension,
|
||||
profile: Profile,
|
||||
inputs: dict[str, str],
|
||||
*,
|
||||
duration_s: int = 3600,
|
||||
) -> MeterQuote | None:
|
||||
if extension.capabilities.pricing_model != "metered":
|
||||
return None
|
||||
backend = resolve_backend(extension)
|
||||
if not hasattr(backend, "estimate_cost"):
|
||||
return None
|
||||
quote = backend.estimate_cost(profile, inputs, duration_s=duration_s)
|
||||
if quote is None:
|
||||
return None
|
||||
if isinstance(quote, MeterQuote):
|
||||
return quote
|
||||
if isinstance(quote, dict):
|
||||
return MeterQuote.model_validate(quote)
|
||||
return None
|
||||
|
||||
|
||||
def settle_usage(
|
||||
status: SandboxStatus,
|
||||
extension: Extension,
|
||||
handle: dict[str, str],
|
||||
*,
|
||||
destroyed_at: datetime,
|
||||
) -> MeterRecord | None:
|
||||
if extension.capabilities.pricing_model != "metered":
|
||||
return MeterRecord(pricing_model="self-hosted")
|
||||
|
||||
duration_s = _duration_seconds(status.ready_at, destroyed_at)
|
||||
backend = resolve_backend(extension)
|
||||
actual_usd: float | None = None
|
||||
|
||||
if hasattr(backend, "meter_actual"):
|
||||
actual_usd = backend.meter_actual(handle, duration_s=duration_s)
|
||||
|
||||
if actual_usd is None and status.meter and status.meter.estimate_usd is not None:
|
||||
hours = duration_s / 3600.0
|
||||
actual_usd = round(status.meter.estimate_usd * max(hours, 1 / 3600), 4)
|
||||
|
||||
estimate = status.meter.estimate_usd if status.meter else None
|
||||
return MeterRecord(
|
||||
pricing_model="metered",
|
||||
estimate_usd=estimate,
|
||||
actual_usd=actual_usd,
|
||||
duration_s=round(duration_s, 1),
|
||||
)
|
||||
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