Merge pull request #3242 from rishijoshi/feat/tenki-compute-provider

feat: add Tenki Cloud compute provider
This commit is contained in:
Mervin Praison authored and GitHub committed 2026-08-14 03:43:18 +01:00
commit 11bcd13797
7 files changed
+529 -3

No files matched your search

@@ -121,6 +121,48 @@ class TestDaytonaComputeUnit:
assert result["exit_code"] == -1
class TestTenkiComputeUnit:
def test_importable(self):
from praisonai.integrations.compute.tenki import TenkiCompute
assert TenkiCompute is not None
def test_provider_name(self):
from praisonai.integrations.compute.tenki import TenkiCompute
compute = TenkiCompute(api_key="test")
assert compute.provider_name == "tenki"
def test_is_available_with_key(self):
from praisonai.integrations.compute.tenki import TenkiCompute
compute = TenkiCompute(api_key="test-key")
assert compute.is_available is True
def test_is_available_without_key(self):
from praisonai.integrations.compute.tenki import TenkiCompute
old = os.environ.pop("TENKI_API_KEY", None)
old_tok = os.environ.pop("TENKI_AUTH_TOKEN", None)
try:
compute = TenkiCompute(api_key="")
assert compute.is_available is False
finally:
if old:
os.environ["TENKI_API_KEY"] = old
if old_tok:
os.environ["TENKI_AUTH_TOKEN"] = old_tok
def test_protocol_methods_exist(self):
from praisonai.integrations.compute.tenki import TenkiCompute
compute = TenkiCompute(api_key="test")
for method in ["provision", "shutdown", "get_status", "execute",
"upload_file", "download_file", "list_instances"]:
assert hasattr(compute, method), f"Missing method: {method}"
def test_execute_nonexistent_instance(self):
from praisonai.integrations.compute.tenki import TenkiCompute
compute = TenkiCompute(api_key="test")
result = asyncio.run(compute.execute("nonexistent", "echo hello"))
assert result["exit_code"] == -1
class TestComputeExports:
def test_all_adapters_from_init(self):
from praisonai.integrations.compute import (
@@ -130,6 +172,7 @@ class TestComputeExports:
E2BCompute,
ModalCompute,
FlyioCompute,
TenkiCompute,
)
assert DockerCompute is not None
assert LocalCompute is not None
@@ -137,6 +180,7 @@ class TestComputeExports:
assert E2BCompute is not None
assert ModalCompute is not None
assert FlyioCompute is not None
assert TenkiCompute is not None
# --------------------------------------------------------------------------- #
@@ -260,6 +304,90 @@ class TestModalComputeIntegration:
asyncio.run(compute.shutdown(info.instance_id))
class TestTenkiComputeIntegration:
@pytest.fixture(autouse=True)
def skip_without_key(self):
if not (os.environ.get("TENKI_API_KEY") or os.environ.get("TENKI_AUTH_TOKEN")):
pytest.skip("TENKI_API_KEY or TENKI_AUTH_TOKEN not set")
def test_provision_execute_shutdown(self):
from praisonai.integrations.compute.tenki import TenkiCompute
from praisonaiagents.managed.protocols import ComputeConfig, InstanceStatus
compute = TenkiCompute()
config = ComputeConfig(
idle_timeout_s=120,
env={"TEST_VAR": "hello_tenki"},
)
info = asyncio.run(compute.provision(config))
assert info.status == InstanceStatus.RUNNING
assert info.provider == "tenki"
assert info.instance_id.startswith("tenki_")
result = asyncio.run(compute.execute(info.instance_id, "echo $TEST_VAR"))
assert result["exit_code"] == 0
assert "hello_tenki" in result["stdout"]
result2 = asyncio.run(compute.execute(info.instance_id, "python3 -c 'print(2+2)'"))
assert result2["exit_code"] == 0
assert "4" in result2["stdout"]
status = asyncio.run(compute.get_status(info.instance_id))
assert status.status == InstanceStatus.RUNNING
instances = asyncio.run(compute.list_instances())
assert len(instances) >= 1
asyncio.run(compute.shutdown(info.instance_id))
status2 = asyncio.run(compute.get_status(info.instance_id))
assert status2.status == InstanceStatus.STOPPED
def test_file_upload_download(self):
import tempfile
from praisonai.integrations.compute.tenki import TenkiCompute
from praisonaiagents.managed.protocols import ComputeConfig
compute = TenkiCompute()
info = asyncio.run(compute.provision(ComputeConfig(idle_timeout_s=120)))
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
f.write("test content from host")
local_path = f.name
ok = asyncio.run(compute.upload_file(info.instance_id, local_path, "/tmp/test.txt"))
assert ok is True
dl_path = local_path + ".download"
ok2 = asyncio.run(compute.download_file(info.instance_id, "/tmp/test.txt", dl_path))
assert ok2 is True
with open(dl_path) as f:
assert f.read() == "test content from host"
os.unlink(local_path)
os.unlink(dl_path)
asyncio.run(compute.shutdown(info.instance_id))
def test_pip_install(self):
from praisonai.integrations.compute.tenki import TenkiCompute
from praisonaiagents.managed.protocols import ComputeConfig
compute = TenkiCompute()
config = ComputeConfig(
packages={"pip": ["requests"]},
idle_timeout_s=120,
)
info = asyncio.run(compute.provision(config))
result = asyncio.run(compute.execute(
info.instance_id,
"python3 -c 'import requests; print(requests.__version__)'",
))
assert result["exit_code"] == 0
assert result["stdout"].strip()
asyncio.run(compute.shutdown(info.instance_id))
class TestDockerComputeIntegration:
@pytest.fixture(autouse=True)
def skip_without_docker(self):
@@ -12,6 +12,7 @@ __all__ = [
"E2BCompute",
"ModalCompute",
"FlyioCompute",
"TenkiCompute",
]
@@ -34,4 +35,7 @@ def __getattr__(name):
if name == "FlyioCompute":
from .flyio import FlyioCompute
return FlyioCompute
if name == "TenkiCompute":
from .tenki import TenkiCompute
return TenkiCompute
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,379 @@
"""
Tenki Compute Provider — cloud sandbox-based compute for managed agents.
Uses the Tenki Cloud SDK to run tools in disposable Linux microVMs.
Requires: ``pip install tenki``
Environment: ``TENKI_API_KEY`` or ``TENKI_AUTH_TOKEN`` (optionally ``TENKI_WORKSPACE_ID``)
"""
import base64
import dataclasses
import logging
import os
import shlex
import time
import uuid
from typing import Any, Dict, List
logger = logging.getLogger(__name__)
class TenkiCompute:
"""Tenki Cloud microVM compute provider.
Satisfies ``ComputeProviderProtocol`` (Core SDK). Uses only stable Tenki
features (ephemeral exec + file I/O over base64) so it works on the default
image without volumes, snapshots or templates.
Example::
from praisonaiagents.managed import ComputeConfig
from praisonai.integrations.compute.tenki import TenkiCompute
compute = TenkiCompute()
config = ComputeConfig(
packages={"pip": ["pandas"]}, # installed on Tenki's stock image
)
info = await compute.provision(config)
result = await compute.execute(info.instance_id, "python -c 'print(1+1)'")
await compute.shutdown(info.instance_id)
"""
def __init__(
self,
api_key: str = "",
workspace_id: str = "",
) -> None:
# Match the SDK's own precedence: explicit key, then TENKI_AUTH_TOKEN,
# then TENKI_API_KEY — so is_available agrees with what Client() resolves.
self._api_key = (
api_key
or os.environ.get("TENKI_AUTH_TOKEN", "")
or os.environ.get("TENKI_API_KEY", "")
)
self._workspace_id = workspace_id or os.environ.get("TENKI_WORKSPACE_ID", "")
self._client = None
self._sandboxes: Dict[str, Dict[str, Any]] = {}
def _get_client(self):
if self._client is None:
try:
from tenki import Client
except ImportError as e:
raise ImportError(
"Tenki SDK required. Install with: pip install tenki"
) from e
# Falls back to TENKI_API_KEY / TENKI_AUTH_TOKEN in the environment.
self._client = Client(auth_token=self._api_key) if self._api_key else Client()
return self._client
def _resolve_workspace(self, client) -> str:
"""Resolve the workspace to create sandboxes under.
The SDK has no "current workspace" default like the CLI. An explicitly
configured TENKI_WORKSPACE_ID is trusted (Tenki validates it at
create()); otherwise fall back to the first workspace on the account.
"""
if self._workspace_id:
return self._workspace_id
identity = client.who_am_i()
workspaces = identity.workspaces
if not workspaces:
raise RuntimeError("No Tenki workspaces available for this API key")
self._workspace_id = workspaces[0].id
return self._workspace_id
@staticmethod
def _sandbox_id(sandbox) -> str:
value = getattr(sandbox, "id", None)
return value() if callable(value) else (value or "")
@property
def provider_name(self) -> str:
return "tenki"
@property
def is_available(self) -> bool:
return bool(self._api_key)
async def provision(self, config) -> Any:
import asyncio
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, self._provision_sync, config)
def _provision_sync(self, config) -> Any:
from praisonaiagents.managed.protocols import InstanceInfo, InstanceStatus
client = self._get_client()
workspace_id = self._resolve_workspace(client)
instance_id = f"tenki_{uuid.uuid4().hex[:12]}"
create_kwargs: Dict[str, Any] = {
"name": instance_id,
"workspace_id": workspace_id,
"cpu_cores": config.cpu,
"memory_mb": config.memory_mb,
"env": config.env or None,
# Tenki's outbound control is a single boolean and can't express
# "limited" per-host allowlists, so only "unrestricted" gets full
# outbound; any restrictive policy ("limited", which the managed CLI
# also uses for --no-networking) disables it.
"allow_outbound": (config.networking or {}).get("type", "unrestricted") == "unrestricted",
}
# metadata["tenki_image"] wins; otherwise honour an explicit
# ComputeConfig.image. Its default is a Docker-style ref, but Tenki's
# registry is a snapshot store (no Docker pull-through), so an unchanged
# default means "use Tenki's stock image." Read that default off the
# dataclass field rather than hardcoding it, so this can't silently
# drift if ComputeConfig's default ever changes.
image = (config.metadata or {}).get("tenki_image")
if not image:
configured = getattr(config, "image", None)
default_image = next(
(f.default for f in dataclasses.fields(config) if f.name == "image"),
None,
) if dataclasses.is_dataclass(config) else None
if configured and configured != default_image:
image = configured
if image:
create_kwargs["image"] = image
if config.auto_shutdown:
create_kwargs["idle_timeout_minutes"] = max(config.idle_timeout_s // 60, 1)
create_kwargs = {k: v for k, v in create_kwargs.items() if v is not None}
sandbox = client.create(**create_kwargs)
sandbox_id = self._sandbox_id(sandbox)
self._sandboxes[instance_id] = {
"sandbox": sandbox,
"sandbox_id": sandbox_id,
"config": config,
"created_at": time.time(),
}
if config.packages:
try:
self._install_packages_sync(sandbox, config.packages)
except Exception:
# A half-provisioned sandbox is useless and still bills — tear it
# down and surface the failure instead of returning RUNNING.
# Terminate BEFORE dropping the handle so a failed terminate keeps
# it tracked (for list_instances / retry) rather than leaking it.
try:
sandbox.terminate()
self._sandboxes.pop(instance_id, None)
except Exception as cleanup_err:
logger.warning("[tenki_compute] cleanup after failed install: %s", cleanup_err)
raise
logger.info("[tenki_compute] provisioned: %s sandbox=%s", instance_id, sandbox_id)
return InstanceInfo(
instance_id=instance_id,
status=InstanceStatus.RUNNING,
endpoint=f"tenki://{sandbox_id}",
provider="tenki",
created_at=time.time(),
)
async def shutdown(self, instance_id: str) -> None:
import asyncio
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, self._shutdown_sync, instance_id)
def _shutdown_sync(self, instance_id: str) -> None:
info = self._sandboxes.get(instance_id)
if not info:
return
# Terminate BEFORE dropping the handle: if this raises (transient network /
# service error), the sandbox stays tracked so get_status still reports it
# and a later shutdown can retry, rather than silently leaking the microVM.
info["sandbox"].terminate()
self._sandboxes.pop(instance_id, None)
logger.info("[tenki_compute] shutdown: %s", instance_id)
@staticmethod
def _is_running(info: Dict[str, Any]) -> bool:
"""Reconcile against live Tenki state instead of trusting the local map.
Tenki can terminate a sandbox server-side (e.g. the configured idle
timeout); without a refresh, get_status/list_instances would keep
reporting RUNNING for a dead sandbox while execute() hits it and fails.
A *successful* refresh returning a non-RUNNING state means it really is
gone. A refresh *exception* (transient outage / rate limit / auth blip)
is "unknown", not "terminated" — assume still running so we don't hide a
sandbox that may be alive and still billing.
"""
try:
info["sandbox"].refresh()
except Exception:
return True
return info["sandbox"].state == "RUNNING"
async def get_status(self, instance_id: str) -> Any:
import asyncio
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, self._get_status_sync, instance_id)
def _get_status_sync(self, instance_id: str) -> Any:
from praisonaiagents.managed.protocols import InstanceInfo, InstanceStatus
info = self._sandboxes.get(instance_id)
if not info:
return InstanceInfo(
instance_id=instance_id,
status=InstanceStatus.STOPPED,
provider="tenki",
)
running = self._is_running(info)
return InstanceInfo(
instance_id=instance_id,
status=InstanceStatus.RUNNING if running else InstanceStatus.STOPPED,
endpoint=f"tenki://{info['sandbox_id']}",
provider="tenki",
created_at=info.get("created_at", 0),
)
async def execute(
self,
instance_id: str,
command: str,
timeout: int = 300,
) -> Dict[str, Any]:
import asyncio
loop = asyncio.get_running_loop()
return await loop.run_in_executor(
None, self._execute_sync, instance_id, command, timeout,
)
def _execute_sync(
self, instance_id: str, command: str, timeout: int,
) -> Dict[str, Any]:
info = self._sandboxes.get(instance_id)
if not info:
return {"stdout": "", "stderr": "Instance not found", "exit_code": -1}
sandbox = info["sandbox"]
try:
result = sandbox.exec("bash", "-lc", command, timeout=timeout)
return {
"stdout": (result.stdout or b"").decode(errors="replace"),
"stderr": (result.stderr or b"").decode(errors="replace"),
"exit_code": result.exit_code,
}
except Exception as e:
return {"stdout": "", "stderr": str(e), "exit_code": -1}
async def upload_file(
self, instance_id: str, local_path: str, remote_path: str,
) -> bool:
import asyncio
loop = asyncio.get_running_loop()
return await loop.run_in_executor(
None, self._upload_sync, instance_id, local_path, remote_path,
)
def _upload_sync(self, instance_id: str, local_path: str, remote_path: str) -> bool:
info = self._sandboxes.get(instance_id)
if not info:
return False
try:
with open(local_path, "rb") as f:
b64 = base64.b64encode(f.read()).decode()
path = shlex.quote(remote_path)
result = info["sandbox"].exec(
"bash", "-lc", f"mkdir -p \"$(dirname {path})\" && base64 -d > {path}", input=b64, timeout=120,
)
return result.exit_code == 0
except Exception as e:
logger.error("[tenki_compute] upload failed: %s", e)
return False
async def download_file(
self, instance_id: str, remote_path: str, local_path: str,
) -> bool:
import asyncio
loop = asyncio.get_running_loop()
return await loop.run_in_executor(
None, self._download_sync, instance_id, remote_path, local_path,
)
def _download_sync(self, instance_id: str, remote_path: str, local_path: str) -> bool:
info = self._sandboxes.get(instance_id)
if not info:
return False
try:
path = shlex.quote(remote_path)
result = info["sandbox"].exec("bash", "-lc", f"base64 -w0 {path}", timeout=120)
if result.exit_code != 0:
logger.error("[tenki_compute] download failed: %s", (result.stderr or b"").decode(errors="replace"))
return False
data = base64.b64decode((result.stdout or b"").strip())
with open(local_path, "wb") as f:
f.write(data)
return True
except Exception as e:
logger.error("[tenki_compute] download failed: %s", e)
return False
async def list_instances(self) -> List[Any]:
import asyncio
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, self._list_instances_sync)
def _list_instances_sync(self) -> List[Any]:
from praisonaiagents.managed.protocols import InstanceInfo, InstanceStatus
result = []
for iid, info in self._sandboxes.items():
# Only surface sandboxes that are actually alive remotely, matching
# the E2B provider (a server-side idle timeout can kill one without
# our local map knowing).
if not self._is_running(info):
continue
result.append(InstanceInfo(
instance_id=iid,
status=InstanceStatus.RUNNING,
endpoint=f"tenki://{info['sandbox_id']}",
provider="tenki",
created_at=info.get("created_at", 0),
))
return result
def _install_packages_sync(self, sandbox, packages: Dict[str, list]) -> None:
# Package specs are shlex-quoted before hitting `bash -lc` — a spec must
# never be able to inject shell commands into the sandbox. Failures raise
# so provision() can tear the sandbox down rather than report it ready.
pip_pkgs = packages.get("pip", [])
if pip_pkgs:
specs = " ".join(shlex.quote(p) for p in pip_pkgs)
# The default Tenki image ships python3 but not pip; bootstrap it.
cmd = (
"if ! command -v pip3 >/dev/null 2>&1; then "
"sudo apt-get update -y && sudo apt-get install -y python3-pip; fi && "
f"pip3 install -q --break-system-packages {specs}"
)
# Log only the count — pip specs can carry private-index URLs/tokens.
logger.info("[tenki_compute] installing %d pip package(s)", len(pip_pkgs))
result = sandbox.exec("bash", "-lc", cmd, timeout=300)
if result.exit_code != 0:
raise RuntimeError(
f"pip install failed: {(result.stderr or b'').decode(errors='replace')}"
)
npm_pkgs = packages.get("npm", [])
if npm_pkgs:
specs = " ".join(shlex.quote(p) for p in npm_pkgs)
cmd = (
"if ! command -v npm >/dev/null 2>&1; then "
"sudo apt-get update -y && sudo apt-get install -y npm; fi && "
f"npm install -g {specs}"
)
logger.info("[tenki_compute] installing %d npm package(s)", len(npm_pkgs))
result = sandbox.exec("bash", "-lc", cmd, timeout=300)
if result.exit_code != 0:
raise RuntimeError(
f"npm install failed: {(result.stderr or b'').decode(errors='replace')}"
)
@@ -117,7 +117,7 @@ class HostedAgent(AnthropicManagedAgent):
from .backend_registry import get_backend_registry
_llm_hints = {"openai", "gemini", "ollama", "local"}
_compute_hints = {"e2b", "modal", "flyio", "daytona", "docker"}
_compute_hints = {"e2b", "modal", "flyio", "daytona", "docker", "tenki"}
if provider in _llm_hints:
hint = (
@@ -1111,7 +1111,7 @@ def ManagedAgent(
return AnthropicManagedAgent(provider=provider, **kwargs)
# Compute provider names - maintain backward compatibility by passing to LocalManagedAgent
elif provider in {"e2b", "modal", "flyio", "daytona", "docker"}:
elif provider in {"e2b", "modal", "flyio", "daytona", "docker", "tenki"}:
warnings.warn(
f"ManagedAgent(provider='{provider}') for compute providers is deprecated. "
f"Use LocalAgent(compute='{provider}', config=LocalAgentConfig(...)) instead.",
@@ -1042,7 +1042,7 @@ class LocalManagedAgent:
Accepts:
- None → no remote compute
- A string: ``"local"``, ``"docker"``, ``"e2b"``, ``"modal"``,
``"daytona"``, ``"flyio"``
``"daytona"``, ``"flyio"``, ``"tenki"``
- An already-instantiated compute provider object
"""
if compute is None:
@@ -1067,6 +1067,9 @@ class LocalManagedAgent:
elif name == "flyio":
from praisonai.integrations.compute.flyio import FlyioCompute
return FlyioCompute()
elif name == "tenki":
from praisonai.integrations.compute.tenki import TenkiCompute
return TenkiCompute()
else:
raise ValueError(f"Unknown compute provider: {name}")
return compute # Already an instance
@@ -1113,6 +1116,15 @@ class LocalManagedAgent:
"working_dir",
self._cfg.get("working_dir") or _default("working_dir", "/workspace"),
),
# Forward the networking policy and provider metadata so providers
# that honour them (e.g. Tenki's allow_outbound / tenki_image) see
# the caller's request instead of always getting the defaults. Copy
# metadata — it's mutated below with env_cfg.metadata.
networking=kwargs.get(
"networking",
self._cfg.get("networking") or _default("networking", {"type": "unrestricted"}),
),
metadata=kwargs.get("metadata", dict(self._cfg.get("metadata") or {})),
auto_shutdown=kwargs.get("auto_shutdown", True),
idle_timeout_s=kwargs.get("idle_timeout_s", 300),
)
+3
View File
@@ -95,6 +95,9 @@ daytona = [
# Note: daytona package not yet available on PyPI
# Install via: pip install git+https://github.com/daytonaio/daytona-python
]
tenki = [
"tenki>=0.5.4",
]
realtime = [
"aiui>=0.3.121,<0.4",
"tavily-python==0.5.0",