eqvps 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- eqvps-0.1.0/LICENSE +21 -0
- eqvps-0.1.0/PKG-INFO +106 -0
- eqvps-0.1.0/README.md +86 -0
- eqvps-0.1.0/eqvps/__init__.py +9 -0
- eqvps-0.1.0/eqvps/sandbox.py +424 -0
- eqvps-0.1.0/eqvps.egg-info/PKG-INFO +106 -0
- eqvps-0.1.0/eqvps.egg-info/SOURCES.txt +10 -0
- eqvps-0.1.0/eqvps.egg-info/dependency_links.txt +1 -0
- eqvps-0.1.0/eqvps.egg-info/top_level.txt +1 -0
- eqvps-0.1.0/pyproject.toml +19 -0
- eqvps-0.1.0/setup.cfg +4 -0
- eqvps-0.1.0/tests/test_live.py +55 -0
eqvps-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 EQVPS
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
eqvps-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: eqvps
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: EQVPS code sandboxes: isolated Firecracker microVMs for AI agents, billed per second from your prepaid balance
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://eqvps.com
|
|
7
|
+
Project-URL: Documentation, https://eqvps.com/en/docs/sandboxes
|
|
8
|
+
Project-URL: API, https://eqvps.com/openapi.json
|
|
9
|
+
Keywords: sandbox,firecracker,microvm,ai-agents,code-execution,eqvps
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
16
|
+
Requires-Python: >=3.8
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# eqvps — code sandboxes for AI agents (Python)
|
|
22
|
+
|
|
23
|
+
Isolated **Firecracker microVMs** in ~1 second: Linux with Python 3.12 + pip, Node.js 22 + npm, bash, git and curl (3–40 GB disk by tariff), internet egress (pip/npm install and git clone work), no inbound ports.
|
|
24
|
+
Billed per second from your prepaid [EQVPS](https://eqvps.com) balance — the same balance as VPS, no subscription. Zero dependencies, Python 3.8+.
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install eqvps
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Docs: [Sandboxes](https://eqvps.com/en/docs/sandboxes) · [Connecting your account (token, config, balance)](https://eqvps.com/en/docs/connect) · [OpenAPI](https://eqvps.com/openapi.json)
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from eqvps import Sandbox
|
|
34
|
+
|
|
35
|
+
with Sandbox.create(tariff="small") as sb: # EQVPS_API_KEY from the environment
|
|
36
|
+
r = sb.run("print(2 + 2)")
|
|
37
|
+
print(r.stdout, r.exit_code) # "4\n" 0
|
|
38
|
+
sb.upload("/root/data.csv", "a,b\n1,2\n")
|
|
39
|
+
print(sb.exec("wc -l /root/data.csv").stdout)
|
|
40
|
+
print(sb.download_text("/root/data.csv"))
|
|
41
|
+
# deleted when the block exits — also on exceptions
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## API key
|
|
45
|
+
Any EQVPS account Bearer token (`POST https://api.eqvps.com/api/v1/eqvps/auth/register`, the MCP `register_account` tool, or your dashboard). Pass `api_key=` or set `EQVPS_API_KEY`. Fund the balance with crypto (`topup_balance`).
|
|
46
|
+
|
|
47
|
+
## Modes and prices
|
|
48
|
+
| | ephemeral (default) | persistent |
|
|
49
|
+
|---|---|---|
|
|
50
|
+
| billing | per second, min 60 s | per started hour; a month costs at most 730 h −25 % |
|
|
51
|
+
| lifetime | idle timeout (default 300 s, max 3600), `ttl` ≤ 24 h | `ttl` ≤ 30 days, survives host maintenance |
|
|
52
|
+
| empty balance | can't create (402) | paused (memory+disk snapshot, not billed), resumes after top-up; snapshot kept 14 days |
|
|
53
|
+
|
|
54
|
+
| tariff | vCPU | RAM | disk | $/hour | persistent month max |
|
|
55
|
+
|---|---|---|---|---|---|
|
|
56
|
+
| `micro` | 0.25 | 512 MB | 3 GB | $0.0165 | $9.03 |
|
|
57
|
+
| `small` | 0.5 | 1 GB | 5 GB | $0.033 | $18.07 |
|
|
58
|
+
| `standard` | 1 | 2 GB | 10 GB | $0.066 | $36.14 |
|
|
59
|
+
| `plus` | 2 | 4 GB | 15 GB | $0.132 | $72.27 |
|
|
60
|
+
| `pro` | 4 | 8 GB | 20 GB | $0.264 | $144.54 |
|
|
61
|
+
| `max` | 8 | 16 GB | 40 GB | $0.528 | $289.08 |
|
|
62
|
+
|
|
63
|
+
Rates: vCPU $0.040/h + RAM $0.013/GiB-h. Live: `tariffs()`. Heavy installs (torch) and builds: `plus` or bigger. If the host is busy, creating a big tariff raises `CapacityError` (503, `retry_after`).
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
sb = Sandbox.create(mode="persistent", tariff="micro", ttl=7 * 86400)
|
|
67
|
+
sid = sb.id # keep it
|
|
68
|
+
sb = Sandbox.connect(sid) # reattach later
|
|
69
|
+
sb.kill()
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## API
|
|
73
|
+
- `Sandbox.create(mode, tariff, idle_timeout, ttl)` · `Sandbox.connect(id)` · `Sandbox.list()` · `eqvps.tariffs()`
|
|
74
|
+
- `sb.run(code, language="python"|"node"|"bash", timeout=30)` → `ExecResult(exit_code, stdout, stderr, timed_out, truncated, duration_ms, ok)`
|
|
75
|
+
- `sb.exec("cmd" | ["argv"], cwd=None, stdin=None, timeout=30)` → `ExecResult`
|
|
76
|
+
- `sb.upload(path, bytes|str, mode=None)` · `sb.download(path) -> bytes` · `sb.download_text(path)` (≤ 5 MB per file)
|
|
77
|
+
- `sb.usage()` → running seconds, CPU used, egress, `billed_usd`, `estimated_total_usd` · `sb.refresh()` · `sb.kill()`
|
|
78
|
+
|
|
79
|
+
Limits: command timeout ≤ 55 s per call (start long jobs with `nohup … &` and poll), output ≤ 1 MiB per stream, files ≤ 5 MB per transfer, 20 concurrent sandboxes and 2 concurrent commands per account.
|
|
80
|
+
|
|
81
|
+
## Long jobs — background tasks (no 55 s limit)
|
|
82
|
+
```python
|
|
83
|
+
task = sb.exec("python3 train.py", background=True) # returns at once with task.id
|
|
84
|
+
result = task.wait(on_output=lambda out, err: print(out, end=""), poll_interval=5)
|
|
85
|
+
print(result.state, result.exit_code) # done 0 (also: failed | killed | timeout)
|
|
86
|
+
|
|
87
|
+
out, err = task.logs() # or poll yourself: only the new output since the last call
|
|
88
|
+
task.kill() # stop it (and its child processes)
|
|
89
|
+
sb.task(task_id) # reattach later, e.g. from another process
|
|
90
|
+
```
|
|
91
|
+
A task may run until the sandbox's maximum lifetime (`timeout=` to cap it). While it runs the sandbox is not idle-deleted, and it is billed like any running time. Up to 8 tasks per sandbox; 8 MiB of output per stream is retained (older output is dropped).
|
|
92
|
+
|
|
93
|
+
## Errors
|
|
94
|
+
All inherit `EqvpsError(status, code, message, retry_after)`: `AuthenticationError` 401 · `InsufficientBalanceError` 402 · `NotFoundError` 404 · `SandboxPausedError` 409 · `SandboxDeletedError` 410 · `FileTooLargeError` 413 · `ValidationError` 422 · `RateLimitError` 429 · `CapacityError` 503 · `SandboxTimeoutError` (HTTP timeout). 429/503 with `Retry-After` are retried automatically (`max_retries=3`). A non-zero exit code or a command timeout is **not** an exception — check `result.ok` / `result.timed_out`.
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from eqvps import Sandbox, InsufficientBalanceError, RateLimitError
|
|
98
|
+
try:
|
|
99
|
+
sb = Sandbox.create()
|
|
100
|
+
except InsufficientBalanceError:
|
|
101
|
+
print("top up your EQVPS balance")
|
|
102
|
+
except RateLimitError as e:
|
|
103
|
+
print("retry in", e.retry_after, "s")
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
REST API reference: https://eqvps.com/openapi.json (tag `sandboxes`) · human guide: https://eqvps.com/en/docs/sandboxes
|
eqvps-0.1.0/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# eqvps — code sandboxes for AI agents (Python)
|
|
2
|
+
|
|
3
|
+
Isolated **Firecracker microVMs** in ~1 second: Linux with Python 3.12 + pip, Node.js 22 + npm, bash, git and curl (3–40 GB disk by tariff), internet egress (pip/npm install and git clone work), no inbound ports.
|
|
4
|
+
Billed per second from your prepaid [EQVPS](https://eqvps.com) balance — the same balance as VPS, no subscription. Zero dependencies, Python 3.8+.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pip install eqvps
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Docs: [Sandboxes](https://eqvps.com/en/docs/sandboxes) · [Connecting your account (token, config, balance)](https://eqvps.com/en/docs/connect) · [OpenAPI](https://eqvps.com/openapi.json)
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
from eqvps import Sandbox
|
|
14
|
+
|
|
15
|
+
with Sandbox.create(tariff="small") as sb: # EQVPS_API_KEY from the environment
|
|
16
|
+
r = sb.run("print(2 + 2)")
|
|
17
|
+
print(r.stdout, r.exit_code) # "4\n" 0
|
|
18
|
+
sb.upload("/root/data.csv", "a,b\n1,2\n")
|
|
19
|
+
print(sb.exec("wc -l /root/data.csv").stdout)
|
|
20
|
+
print(sb.download_text("/root/data.csv"))
|
|
21
|
+
# deleted when the block exits — also on exceptions
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## API key
|
|
25
|
+
Any EQVPS account Bearer token (`POST https://api.eqvps.com/api/v1/eqvps/auth/register`, the MCP `register_account` tool, or your dashboard). Pass `api_key=` or set `EQVPS_API_KEY`. Fund the balance with crypto (`topup_balance`).
|
|
26
|
+
|
|
27
|
+
## Modes and prices
|
|
28
|
+
| | ephemeral (default) | persistent |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| billing | per second, min 60 s | per started hour; a month costs at most 730 h −25 % |
|
|
31
|
+
| lifetime | idle timeout (default 300 s, max 3600), `ttl` ≤ 24 h | `ttl` ≤ 30 days, survives host maintenance |
|
|
32
|
+
| empty balance | can't create (402) | paused (memory+disk snapshot, not billed), resumes after top-up; snapshot kept 14 days |
|
|
33
|
+
|
|
34
|
+
| tariff | vCPU | RAM | disk | $/hour | persistent month max |
|
|
35
|
+
|---|---|---|---|---|---|
|
|
36
|
+
| `micro` | 0.25 | 512 MB | 3 GB | $0.0165 | $9.03 |
|
|
37
|
+
| `small` | 0.5 | 1 GB | 5 GB | $0.033 | $18.07 |
|
|
38
|
+
| `standard` | 1 | 2 GB | 10 GB | $0.066 | $36.14 |
|
|
39
|
+
| `plus` | 2 | 4 GB | 15 GB | $0.132 | $72.27 |
|
|
40
|
+
| `pro` | 4 | 8 GB | 20 GB | $0.264 | $144.54 |
|
|
41
|
+
| `max` | 8 | 16 GB | 40 GB | $0.528 | $289.08 |
|
|
42
|
+
|
|
43
|
+
Rates: vCPU $0.040/h + RAM $0.013/GiB-h. Live: `tariffs()`. Heavy installs (torch) and builds: `plus` or bigger. If the host is busy, creating a big tariff raises `CapacityError` (503, `retry_after`).
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
sb = Sandbox.create(mode="persistent", tariff="micro", ttl=7 * 86400)
|
|
47
|
+
sid = sb.id # keep it
|
|
48
|
+
sb = Sandbox.connect(sid) # reattach later
|
|
49
|
+
sb.kill()
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## API
|
|
53
|
+
- `Sandbox.create(mode, tariff, idle_timeout, ttl)` · `Sandbox.connect(id)` · `Sandbox.list()` · `eqvps.tariffs()`
|
|
54
|
+
- `sb.run(code, language="python"|"node"|"bash", timeout=30)` → `ExecResult(exit_code, stdout, stderr, timed_out, truncated, duration_ms, ok)`
|
|
55
|
+
- `sb.exec("cmd" | ["argv"], cwd=None, stdin=None, timeout=30)` → `ExecResult`
|
|
56
|
+
- `sb.upload(path, bytes|str, mode=None)` · `sb.download(path) -> bytes` · `sb.download_text(path)` (≤ 5 MB per file)
|
|
57
|
+
- `sb.usage()` → running seconds, CPU used, egress, `billed_usd`, `estimated_total_usd` · `sb.refresh()` · `sb.kill()`
|
|
58
|
+
|
|
59
|
+
Limits: command timeout ≤ 55 s per call (start long jobs with `nohup … &` and poll), output ≤ 1 MiB per stream, files ≤ 5 MB per transfer, 20 concurrent sandboxes and 2 concurrent commands per account.
|
|
60
|
+
|
|
61
|
+
## Long jobs — background tasks (no 55 s limit)
|
|
62
|
+
```python
|
|
63
|
+
task = sb.exec("python3 train.py", background=True) # returns at once with task.id
|
|
64
|
+
result = task.wait(on_output=lambda out, err: print(out, end=""), poll_interval=5)
|
|
65
|
+
print(result.state, result.exit_code) # done 0 (also: failed | killed | timeout)
|
|
66
|
+
|
|
67
|
+
out, err = task.logs() # or poll yourself: only the new output since the last call
|
|
68
|
+
task.kill() # stop it (and its child processes)
|
|
69
|
+
sb.task(task_id) # reattach later, e.g. from another process
|
|
70
|
+
```
|
|
71
|
+
A task may run until the sandbox's maximum lifetime (`timeout=` to cap it). While it runs the sandbox is not idle-deleted, and it is billed like any running time. Up to 8 tasks per sandbox; 8 MiB of output per stream is retained (older output is dropped).
|
|
72
|
+
|
|
73
|
+
## Errors
|
|
74
|
+
All inherit `EqvpsError(status, code, message, retry_after)`: `AuthenticationError` 401 · `InsufficientBalanceError` 402 · `NotFoundError` 404 · `SandboxPausedError` 409 · `SandboxDeletedError` 410 · `FileTooLargeError` 413 · `ValidationError` 422 · `RateLimitError` 429 · `CapacityError` 503 · `SandboxTimeoutError` (HTTP timeout). 429/503 with `Retry-After` are retried automatically (`max_retries=3`). A non-zero exit code or a command timeout is **not** an exception — check `result.ok` / `result.timed_out`.
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from eqvps import Sandbox, InsufficientBalanceError, RateLimitError
|
|
78
|
+
try:
|
|
79
|
+
sb = Sandbox.create()
|
|
80
|
+
except InsufficientBalanceError:
|
|
81
|
+
print("top up your EQVPS balance")
|
|
82
|
+
except RateLimitError as e:
|
|
83
|
+
print("retry in", e.retry_after, "s")
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
REST API reference: https://eqvps.com/openapi.json (tag `sandboxes`) · human guide: https://eqvps.com/en/docs/sandboxes
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""EQVPS Python SDK — code sandboxes (Firecracker microVMs) billed from your prepaid EQVPS balance."""
|
|
2
|
+
|
|
3
|
+
from .sandbox import ( # noqa: F401
|
|
4
|
+
AuthenticationError, CapacityError, EqvpsError, ExecResult, FileTooLargeError, InsufficientBalanceError,
|
|
5
|
+
NotFoundError, RateLimitError, Sandbox, SandboxDeletedError, SandboxPausedError, SandboxTimeoutError,
|
|
6
|
+
ValidationError, tariffs, Task, TaskResult,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
"""EQVPS code sandboxes — Python SDK (zero dependencies, Python 3.8+).
|
|
2
|
+
|
|
3
|
+
Isolated Firecracker microVMs for running code, billed per second from your prepaid EQVPS balance.
|
|
4
|
+
|
|
5
|
+
from eqvps import Sandbox
|
|
6
|
+
|
|
7
|
+
with Sandbox.create() as sb: # EQVPS_API_KEY from the environment
|
|
8
|
+
print(sb.run("print(2 + 2)").stdout) # -> "4\\n"
|
|
9
|
+
# the sandbox is deleted when the block exits
|
|
10
|
+
|
|
11
|
+
REST contract: https://eqvps.com/openapi.json (tag "sandboxes"); see CONTRACT.md for other languages.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import socket
|
|
18
|
+
import ssl
|
|
19
|
+
import time
|
|
20
|
+
import urllib.error
|
|
21
|
+
import urllib.parse
|
|
22
|
+
import urllib.request
|
|
23
|
+
from typing import Any, Dict, List, Optional, Union
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"Sandbox", "ExecResult", "Task", "TaskResult", "EqvpsError", "AuthenticationError", "InsufficientBalanceError", "NotFoundError",
|
|
27
|
+
"SandboxPausedError", "SandboxDeletedError", "FileTooLargeError", "RateLimitError", "CapacityError",
|
|
28
|
+
"ValidationError", "SandboxTimeoutError", "tariffs",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
DEFAULT_BASE_URL = "https://api.eqvps.com/api/v1/eqvps"
|
|
32
|
+
USER_AGENT = "eqvps-python/0.1.0"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ------------------------------------------------------------------ errors
|
|
36
|
+
|
|
37
|
+
class EqvpsError(Exception):
|
|
38
|
+
"""Any API error. `status` is the HTTP status, `code` the stable machine-readable error code."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, message: str, status: int = 0, code: str = "error", body: Optional[dict] = None):
|
|
41
|
+
super().__init__(message)
|
|
42
|
+
self.message = message
|
|
43
|
+
self.status = status
|
|
44
|
+
self.code = code
|
|
45
|
+
self.body = body or {}
|
|
46
|
+
self.retry_after: Optional[int] = self.body.get("retry_after")
|
|
47
|
+
|
|
48
|
+
def __str__(self) -> str:
|
|
49
|
+
return f"{self.code} ({self.status}): {self.message}" if self.status else self.message
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class AuthenticationError(EqvpsError):
|
|
53
|
+
"""401 — missing or invalid API key."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class InsufficientBalanceError(EqvpsError):
|
|
57
|
+
"""402 — the prepaid balance cannot cover the sandbox. Top up and retry."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class NotFoundError(EqvpsError):
|
|
61
|
+
"""404 — no such sandbox (or file) on this account."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class SandboxPausedError(EqvpsError):
|
|
65
|
+
"""409 — the sandbox is paused (empty balance) or changing state; it resumes after a top-up."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class SandboxDeletedError(EqvpsError):
|
|
69
|
+
"""410 — the sandbox was deleted (paused snapshot kept 14 days, then removed). It cannot be resumed."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class FileTooLargeError(EqvpsError):
|
|
73
|
+
"""413 — a file transfer is limited to 5 MB."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ValidationError(EqvpsError):
|
|
77
|
+
"""422 — invalid parameters."""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class RateLimitError(EqvpsError):
|
|
81
|
+
"""429 — too many requests or the concurrent sandbox limit is reached. See `retry_after`."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class CapacityError(EqvpsError):
|
|
85
|
+
"""503 — no capacity or maintenance right now. See `retry_after`."""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class SandboxTimeoutError(EqvpsError):
|
|
89
|
+
"""The HTTP request itself timed out (not the command — see ExecResult.timed_out)."""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
_BY_STATUS = {401: AuthenticationError, 402: InsufficientBalanceError, 404: NotFoundError, 409: SandboxPausedError,
|
|
93
|
+
410: SandboxDeletedError, 413: FileTooLargeError, 422: ValidationError, 429: RateLimitError, 503: CapacityError}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ------------------------------------------------------------------ transport
|
|
97
|
+
|
|
98
|
+
class _Client:
|
|
99
|
+
def __init__(self, api_key: Optional[str], base_url: Optional[str], timeout: float, max_retries: int):
|
|
100
|
+
self.api_key = api_key or os.environ.get("EQVPS_API_KEY")
|
|
101
|
+
if not self.api_key:
|
|
102
|
+
raise AuthenticationError("No API key: pass api_key=... or set EQVPS_API_KEY (an EQVPS account Bearer token).")
|
|
103
|
+
self.base_url = (base_url or os.environ.get("EQVPS_API_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
104
|
+
self.timeout = timeout
|
|
105
|
+
self.max_retries = max_retries
|
|
106
|
+
self._ssl = ssl.create_default_context()
|
|
107
|
+
|
|
108
|
+
def request(self, method: str, path: str, body: Optional[dict] = None, query: Optional[dict] = None,
|
|
109
|
+
timeout: Optional[float] = None, retry: bool = True) -> Any:
|
|
110
|
+
url = self.base_url + path + ("?" + urllib.parse.urlencode(query) if query else "")
|
|
111
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
112
|
+
headers = {"Authorization": "Bearer " + self.api_key, "Accept": "application/json", "User-Agent": USER_AGENT}
|
|
113
|
+
if data is not None:
|
|
114
|
+
headers["Content-Type"] = "application/json"
|
|
115
|
+
attempt = 0
|
|
116
|
+
while True:
|
|
117
|
+
attempt += 1
|
|
118
|
+
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
|
119
|
+
try:
|
|
120
|
+
with urllib.request.urlopen(req, timeout=timeout or self.timeout, context=self._ssl) as resp:
|
|
121
|
+
raw = resp.read()
|
|
122
|
+
return json.loads(raw) if raw else None
|
|
123
|
+
except urllib.error.HTTPError as e:
|
|
124
|
+
raw = e.read()
|
|
125
|
+
try:
|
|
126
|
+
j = json.loads(raw) if raw else {}
|
|
127
|
+
except ValueError:
|
|
128
|
+
j = {"message": raw.decode("utf-8", "replace")[:300]}
|
|
129
|
+
if not isinstance(j, dict):
|
|
130
|
+
j = {}
|
|
131
|
+
ra = j.get("retry_after") or e.headers.get("Retry-After")
|
|
132
|
+
# 429/503 = отклонено ДО выполнения (лимит/ёмкость) → безопасно повторить, в т.ч. run/exec
|
|
133
|
+
if e.code in (429, 503) and attempt <= self.max_retries and ra is not None:
|
|
134
|
+
time.sleep(min(float(ra), 30.0))
|
|
135
|
+
continue
|
|
136
|
+
cls = _BY_STATUS.get(e.code, EqvpsError)
|
|
137
|
+
if e.code == 401:
|
|
138
|
+
j.setdefault("error", "unauthenticated")
|
|
139
|
+
raise cls(str(j.get("message") or e.reason), status=e.code, code=str(j.get("error") or "http_%d" % e.code), body=j) from None
|
|
140
|
+
except (socket.timeout, TimeoutError) as e:
|
|
141
|
+
raise SandboxTimeoutError("Request timed out: %s %s" % (method, path), code="request_timeout") from e
|
|
142
|
+
except urllib.error.URLError as e:
|
|
143
|
+
if retry and method == "GET" and attempt <= self.max_retries:
|
|
144
|
+
time.sleep(1.5 * attempt)
|
|
145
|
+
continue
|
|
146
|
+
raise EqvpsError("Network error: %s" % e.reason, code="network_error") from e
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ------------------------------------------------------------------ results
|
|
150
|
+
|
|
151
|
+
class ExecResult:
|
|
152
|
+
"""Result of run()/exec(). A non-zero exit code is NOT an exception — check `ok` / `exit_code`."""
|
|
153
|
+
|
|
154
|
+
__slots__ = ("exit_code", "stdout", "stderr", "timed_out", "truncated", "duration_ms")
|
|
155
|
+
|
|
156
|
+
def __init__(self, d: dict):
|
|
157
|
+
self.exit_code: Optional[int] = d.get("exit_code")
|
|
158
|
+
self.stdout: str = d.get("stdout", "")
|
|
159
|
+
self.stderr: str = d.get("stderr", "")
|
|
160
|
+
self.timed_out: bool = bool(d.get("timed_out"))
|
|
161
|
+
self.truncated: bool = bool(d.get("truncated"))
|
|
162
|
+
self.duration_ms: Optional[int] = d.get("duration_ms")
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def ok(self) -> bool:
|
|
166
|
+
return self.exit_code == 0 and not self.timed_out
|
|
167
|
+
|
|
168
|
+
def __repr__(self) -> str:
|
|
169
|
+
return "ExecResult(exit_code=%r, timed_out=%r, stdout=%r, stderr=%r)" % (self.exit_code, self.timed_out, self.stdout[:80], self.stderr[:80])
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class TaskResult:
|
|
173
|
+
"""Final state of a background task (Task.wait())."""
|
|
174
|
+
|
|
175
|
+
__slots__ = ("task_id", "state", "exit_code", "stdout", "stderr", "running_seconds")
|
|
176
|
+
|
|
177
|
+
def __init__(self, task_id: str, d: dict, stdout: str, stderr: str):
|
|
178
|
+
self.task_id = task_id
|
|
179
|
+
self.state: str = d.get("state", "")
|
|
180
|
+
self.exit_code: Optional[int] = d.get("exit_code")
|
|
181
|
+
self.stdout, self.stderr = stdout, stderr
|
|
182
|
+
self.running_seconds: Optional[float] = d.get("running_seconds")
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def ok(self) -> bool:
|
|
186
|
+
return self.state == "done" and self.exit_code == 0
|
|
187
|
+
|
|
188
|
+
def __repr__(self) -> str:
|
|
189
|
+
return "TaskResult(state=%r, exit_code=%r, stdout=%r)" % (self.state, self.exit_code, self.stdout[-80:])
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class Task:
|
|
193
|
+
"""A background task (run/exec with background=True): no 55 s limit, output collected in the sandbox.
|
|
194
|
+
|
|
195
|
+
task.logs() → new (stdout, stderr) since the last call · task.wait() → TaskResult · task.kill()
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
def __init__(self, sandbox: "Sandbox", data: dict):
|
|
199
|
+
self._sb = sandbox
|
|
200
|
+
self.id: str = data["task_id"]
|
|
201
|
+
self._data = data
|
|
202
|
+
self._out = self._err = 0
|
|
203
|
+
|
|
204
|
+
def _poll(self, max_bytes: int = 262144) -> dict:
|
|
205
|
+
d = self._sb._c.request("GET", "/sandboxes/%s/tasks/%s" % (self._sb.id, self.id),
|
|
206
|
+
query={"stdout_offset": self._out, "stderr_offset": self._err, "max_bytes": max_bytes})
|
|
207
|
+
self._out, self._err = d.get("stdout_next_offset", self._out), d.get("stderr_next_offset", self._err)
|
|
208
|
+
self._data = d
|
|
209
|
+
return d
|
|
210
|
+
|
|
211
|
+
def status(self) -> dict:
|
|
212
|
+
"""State without consuming output: running | done | failed | killed | timeout, exit_code, totals."""
|
|
213
|
+
return self._sb._c.request("GET", "/sandboxes/%s/tasks/%s" % (self._sb.id, self.id),
|
|
214
|
+
query={"stdout_offset": self._out, "stderr_offset": self._err, "max_bytes": 0})
|
|
215
|
+
|
|
216
|
+
def logs(self):
|
|
217
|
+
"""New output since the previous call: (stdout, stderr)."""
|
|
218
|
+
d = self._poll()
|
|
219
|
+
return d.get("stdout", ""), d.get("stderr", "")
|
|
220
|
+
|
|
221
|
+
@property
|
|
222
|
+
def state(self) -> str:
|
|
223
|
+
return self._data.get("state", "")
|
|
224
|
+
|
|
225
|
+
@property
|
|
226
|
+
def done(self) -> bool:
|
|
227
|
+
return self._data.get("state", "running") != "running"
|
|
228
|
+
|
|
229
|
+
def wait(self, timeout: Optional[float] = None, poll_interval: float = 2.0, on_output=None) -> TaskResult:
|
|
230
|
+
"""Poll until the task finishes. `on_output(stdout_chunk, stderr_chunk)` is called with new output.
|
|
231
|
+
Raises SandboxTimeoutError if `timeout` seconds pass first (the task keeps running)."""
|
|
232
|
+
start, out, err = time.time(), [], []
|
|
233
|
+
while True:
|
|
234
|
+
d = self._poll()
|
|
235
|
+
o, e = d.get("stdout", ""), d.get("stderr", "")
|
|
236
|
+
if o or e:
|
|
237
|
+
out.append(o); err.append(e)
|
|
238
|
+
if on_output:
|
|
239
|
+
on_output(o, e)
|
|
240
|
+
if d.get("state", "running") != "running":
|
|
241
|
+
# дочитать хвост, если вывод больше одной порции
|
|
242
|
+
while d.get("stdout_next_offset", 0) < d.get("stdout_total", 0) or d.get("stderr_next_offset", 0) < d.get("stderr_total", 0):
|
|
243
|
+
d = self._poll(); out.append(d.get("stdout", "")); err.append(d.get("stderr", ""))
|
|
244
|
+
if on_output:
|
|
245
|
+
on_output(d.get("stdout", ""), d.get("stderr", ""))
|
|
246
|
+
return TaskResult(self.id, d, "".join(out), "".join(err))
|
|
247
|
+
if timeout is not None and time.time() - start > timeout:
|
|
248
|
+
raise SandboxTimeoutError("Task %s still running after %ss" % (self.id, timeout), code="wait_timeout")
|
|
249
|
+
time.sleep(poll_interval)
|
|
250
|
+
|
|
251
|
+
def kill(self) -> dict:
|
|
252
|
+
"""Stop the task and its processes."""
|
|
253
|
+
self._data = self._sb._c.request("DELETE", "/sandboxes/%s/tasks/%s" % (self._sb.id, self.id), retry=False)
|
|
254
|
+
return self._data
|
|
255
|
+
|
|
256
|
+
def __repr__(self) -> str:
|
|
257
|
+
return "Task(id=%r, state=%r)" % (self.id, self.state)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# ------------------------------------------------------------------ sandbox
|
|
261
|
+
|
|
262
|
+
class Sandbox:
|
|
263
|
+
"""One sandbox. Create with Sandbox.create(), reattach with Sandbox.connect(id).
|
|
264
|
+
|
|
265
|
+
Used as a context manager the sandbox is deleted on exit (also on exceptions).
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
def __init__(self, data: dict, client: _Client):
|
|
269
|
+
self._c = client
|
|
270
|
+
self._data = data
|
|
271
|
+
|
|
272
|
+
# ---- lifecycle
|
|
273
|
+
@classmethod
|
|
274
|
+
def create(cls, mode: str = "ephemeral", tariff: str = "small", idle_timeout: Optional[int] = None,
|
|
275
|
+
ttl: Optional[int] = None, *, api_key: Optional[str] = None, base_url: Optional[str] = None,
|
|
276
|
+
timeout: float = 70.0, max_retries: int = 3) -> "Sandbox":
|
|
277
|
+
"""Start a sandbox (ready in ~1 s).
|
|
278
|
+
|
|
279
|
+
mode: "ephemeral" (per-second billing, min 60 s, auto-deleted after `idle_timeout` s idle, default 300)
|
|
280
|
+
or "persistent" (per started hour, month capped at 730 h -25%, survives host maintenance).
|
|
281
|
+
tariff (vCPU/RAM/disk): "micro" 0.25/512 MB/3 GB · "small" 0.5/1 GB/5 GB · "standard" 1/2 GB/10 GB ·
|
|
282
|
+
"plus" 2/4 GB/15 GB · "pro" 4/8 GB/20 GB · "max" 8/16 GB/40 GB. ttl: max lifetime in seconds.
|
|
283
|
+
Raises CapacityError (503) if the host has no free CPU/RAM for that tariff right now.
|
|
284
|
+
Raises InsufficientBalanceError if the balance is empty.
|
|
285
|
+
"""
|
|
286
|
+
c = _Client(api_key, base_url, timeout, max_retries)
|
|
287
|
+
body: Dict[str, Any] = {"mode": mode, "tariff": tariff}
|
|
288
|
+
if idle_timeout is not None:
|
|
289
|
+
body["idle_timeout"] = idle_timeout
|
|
290
|
+
if ttl is not None:
|
|
291
|
+
body["ttl"] = ttl
|
|
292
|
+
return cls(c.request("POST", "/sandboxes", body), c)
|
|
293
|
+
|
|
294
|
+
@classmethod
|
|
295
|
+
def connect(cls, sandbox_id: str, *, api_key: Optional[str] = None, base_url: Optional[str] = None,
|
|
296
|
+
timeout: float = 70.0, max_retries: int = 3) -> "Sandbox":
|
|
297
|
+
"""Attach to an existing sandbox of this account (e.g. a persistent one)."""
|
|
298
|
+
c = _Client(api_key, base_url, timeout, max_retries)
|
|
299
|
+
return cls(c.request("GET", "/sandboxes/" + sandbox_id), c)
|
|
300
|
+
|
|
301
|
+
@classmethod
|
|
302
|
+
def list(cls, *, api_key: Optional[str] = None, base_url: Optional[str] = None) -> List["Sandbox"]:
|
|
303
|
+
"""All sandboxes of this account (running and paused)."""
|
|
304
|
+
c = _Client(api_key, base_url, 30.0, 3)
|
|
305
|
+
return [cls(d, c) for d in (c.request("GET", "/sandboxes") or {}).get("data", [])]
|
|
306
|
+
|
|
307
|
+
def kill(self) -> None:
|
|
308
|
+
"""Delete the sandbox now. Billing stops; deleting an already-gone sandbox is not an error."""
|
|
309
|
+
try:
|
|
310
|
+
self._c.request("DELETE", "/sandboxes/" + self.id, retry=False)
|
|
311
|
+
except (NotFoundError, SandboxDeletedError):
|
|
312
|
+
pass
|
|
313
|
+
self._data["state"] = "deleted"
|
|
314
|
+
|
|
315
|
+
def refresh(self) -> "Sandbox":
|
|
316
|
+
self._data = self._c.request("GET", "/sandboxes/" + self.id)
|
|
317
|
+
return self
|
|
318
|
+
|
|
319
|
+
def __enter__(self) -> "Sandbox":
|
|
320
|
+
return self
|
|
321
|
+
|
|
322
|
+
def __exit__(self, *exc) -> None:
|
|
323
|
+
self.kill()
|
|
324
|
+
|
|
325
|
+
# ---- properties
|
|
326
|
+
@property
|
|
327
|
+
def id(self) -> str:
|
|
328
|
+
return self._data["id"]
|
|
329
|
+
|
|
330
|
+
@property
|
|
331
|
+
def mode(self) -> str:
|
|
332
|
+
return self._data.get("mode", "")
|
|
333
|
+
|
|
334
|
+
@property
|
|
335
|
+
def tariff(self) -> str:
|
|
336
|
+
return self._data.get("tariff", "")
|
|
337
|
+
|
|
338
|
+
@property
|
|
339
|
+
def state(self) -> str:
|
|
340
|
+
"""running | starting | paused | pausing | resuming | deleting | deleted"""
|
|
341
|
+
return self._data.get("state", "")
|
|
342
|
+
|
|
343
|
+
@property
|
|
344
|
+
def info(self) -> dict:
|
|
345
|
+
"""Raw sandbox object as returned by the API."""
|
|
346
|
+
return dict(self._data)
|
|
347
|
+
|
|
348
|
+
def __repr__(self) -> str:
|
|
349
|
+
return "Sandbox(id=%r, mode=%r, tariff=%r, state=%r)" % (self.id, self.mode, self.tariff, self.state)
|
|
350
|
+
|
|
351
|
+
# ---- code & commands
|
|
352
|
+
def run(self, code: str, language: str = "python", timeout: Optional[int] = None, background: bool = False) -> Union[ExecResult, "Task"]:
|
|
353
|
+
"""Run a code snippet passed on stdin: language "python" (3.12), "node" (Node.js 22) or "bash".
|
|
354
|
+
Synchronous: timeout ≤ 55 s (default 30) → ExecResult. background=True → Task immediately (no 55 s limit;
|
|
355
|
+
timeout defaults to the sandbox's remaining lifetime)."""
|
|
356
|
+
body: Dict[str, Any] = {"language": language, "code": code}
|
|
357
|
+
if background:
|
|
358
|
+
body["async"] = True
|
|
359
|
+
if timeout:
|
|
360
|
+
body["timeout"] = timeout
|
|
361
|
+
return Task(self, self._c.request("POST", "/sandboxes/%s/run" % self.id, body))
|
|
362
|
+
body["timeout"] = timeout or 30
|
|
363
|
+
return ExecResult(self._c.request("POST", "/sandboxes/%s/run" % self.id, body, timeout=body["timeout"] + 20, retry=False))
|
|
364
|
+
|
|
365
|
+
def exec(self, command: Union[str, List[str]], cwd: Optional[str] = None, stdin: Optional[str] = None,
|
|
366
|
+
timeout: Optional[int] = None, background: bool = False) -> Union[ExecResult, "Task"]:
|
|
367
|
+
"""Execute a shell command (string → bash -lc) or an argv list. background=True → Task (see run())."""
|
|
368
|
+
body: Dict[str, Any] = {}
|
|
369
|
+
if background:
|
|
370
|
+
body["async"] = True
|
|
371
|
+
if timeout:
|
|
372
|
+
body["timeout"] = timeout
|
|
373
|
+
else:
|
|
374
|
+
timeout = timeout or 30
|
|
375
|
+
body["timeout"] = timeout
|
|
376
|
+
if isinstance(command, str):
|
|
377
|
+
body["command"] = command
|
|
378
|
+
else:
|
|
379
|
+
body["argv"] = list(command)
|
|
380
|
+
if cwd:
|
|
381
|
+
body["cwd"] = cwd
|
|
382
|
+
if stdin:
|
|
383
|
+
body["stdin"] = stdin
|
|
384
|
+
if background:
|
|
385
|
+
return Task(self, self._c.request("POST", "/sandboxes/%s/exec" % self.id, body))
|
|
386
|
+
return ExecResult(self._c.request("POST", "/sandboxes/%s/exec" % self.id, body, timeout=timeout + 20, retry=False))
|
|
387
|
+
|
|
388
|
+
def task(self, task_id: str) -> "Task":
|
|
389
|
+
"""Reattach to a background task by id."""
|
|
390
|
+
return Task(self, {"task_id": task_id})
|
|
391
|
+
|
|
392
|
+
def tasks(self) -> list:
|
|
393
|
+
"""Background tasks of this sandbox (summaries)."""
|
|
394
|
+
return self._c.request("GET", "/sandboxes/%s/tasks" % self.id).get("data", [])
|
|
395
|
+
|
|
396
|
+
# ---- files (≤ 5 MB per transfer)
|
|
397
|
+
def upload(self, path: str, content: Union[bytes, str], mode: Optional[str] = None) -> int:
|
|
398
|
+
"""Write a file at an absolute path; returns its size in bytes."""
|
|
399
|
+
raw = content.encode("utf-8") if isinstance(content, str) else bytes(content)
|
|
400
|
+
body: Dict[str, Any] = {"path": path, "content_base64": base64.b64encode(raw).decode()}
|
|
401
|
+
if mode:
|
|
402
|
+
body["mode"] = mode
|
|
403
|
+
return int(self._c.request("PUT", "/sandboxes/%s/files" % self.id, body, retry=False).get("size", len(raw)))
|
|
404
|
+
|
|
405
|
+
def download(self, path: str) -> bytes:
|
|
406
|
+
"""Read a file (bytes)."""
|
|
407
|
+
d = self._c.request("GET", "/sandboxes/%s/files" % self.id, query={"path": path})
|
|
408
|
+
return base64.b64decode(d.get("content_base64", ""))
|
|
409
|
+
|
|
410
|
+
def download_text(self, path: str, encoding: str = "utf-8") -> str:
|
|
411
|
+
return self.download(path).decode(encoding)
|
|
412
|
+
|
|
413
|
+
# ---- billing
|
|
414
|
+
def usage(self) -> dict:
|
|
415
|
+
"""Running seconds, CPU seconds used, egress bytes, price per hour, billed_usd and estimated_total_usd."""
|
|
416
|
+
return self._c.request("GET", "/sandboxes/%s/usage" % self.id)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def tariffs(base_url: Optional[str] = None) -> dict:
|
|
420
|
+
"""Public tariffs, prices, billing model and limits (no API key needed)."""
|
|
421
|
+
url = (base_url or os.environ.get("EQVPS_API_URL") or DEFAULT_BASE_URL).rstrip("/") + "/sandboxes/tariffs"
|
|
422
|
+
req = urllib.request.Request(url, headers={"Accept": "application/json", "User-Agent": USER_AGENT})
|
|
423
|
+
with urllib.request.urlopen(req, timeout=30, context=ssl.create_default_context()) as r:
|
|
424
|
+
return json.loads(r.read())
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: eqvps
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: EQVPS code sandboxes: isolated Firecracker microVMs for AI agents, billed per second from your prepaid balance
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://eqvps.com
|
|
7
|
+
Project-URL: Documentation, https://eqvps.com/en/docs/sandboxes
|
|
8
|
+
Project-URL: API, https://eqvps.com/openapi.json
|
|
9
|
+
Keywords: sandbox,firecracker,microvm,ai-agents,code-execution,eqvps
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
16
|
+
Requires-Python: >=3.8
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# eqvps — code sandboxes for AI agents (Python)
|
|
22
|
+
|
|
23
|
+
Isolated **Firecracker microVMs** in ~1 second: Linux with Python 3.12 + pip, Node.js 22 + npm, bash, git and curl (3–40 GB disk by tariff), internet egress (pip/npm install and git clone work), no inbound ports.
|
|
24
|
+
Billed per second from your prepaid [EQVPS](https://eqvps.com) balance — the same balance as VPS, no subscription. Zero dependencies, Python 3.8+.
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install eqvps
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Docs: [Sandboxes](https://eqvps.com/en/docs/sandboxes) · [Connecting your account (token, config, balance)](https://eqvps.com/en/docs/connect) · [OpenAPI](https://eqvps.com/openapi.json)
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from eqvps import Sandbox
|
|
34
|
+
|
|
35
|
+
with Sandbox.create(tariff="small") as sb: # EQVPS_API_KEY from the environment
|
|
36
|
+
r = sb.run("print(2 + 2)")
|
|
37
|
+
print(r.stdout, r.exit_code) # "4\n" 0
|
|
38
|
+
sb.upload("/root/data.csv", "a,b\n1,2\n")
|
|
39
|
+
print(sb.exec("wc -l /root/data.csv").stdout)
|
|
40
|
+
print(sb.download_text("/root/data.csv"))
|
|
41
|
+
# deleted when the block exits — also on exceptions
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## API key
|
|
45
|
+
Any EQVPS account Bearer token (`POST https://api.eqvps.com/api/v1/eqvps/auth/register`, the MCP `register_account` tool, or your dashboard). Pass `api_key=` or set `EQVPS_API_KEY`. Fund the balance with crypto (`topup_balance`).
|
|
46
|
+
|
|
47
|
+
## Modes and prices
|
|
48
|
+
| | ephemeral (default) | persistent |
|
|
49
|
+
|---|---|---|
|
|
50
|
+
| billing | per second, min 60 s | per started hour; a month costs at most 730 h −25 % |
|
|
51
|
+
| lifetime | idle timeout (default 300 s, max 3600), `ttl` ≤ 24 h | `ttl` ≤ 30 days, survives host maintenance |
|
|
52
|
+
| empty balance | can't create (402) | paused (memory+disk snapshot, not billed), resumes after top-up; snapshot kept 14 days |
|
|
53
|
+
|
|
54
|
+
| tariff | vCPU | RAM | disk | $/hour | persistent month max |
|
|
55
|
+
|---|---|---|---|---|---|
|
|
56
|
+
| `micro` | 0.25 | 512 MB | 3 GB | $0.0165 | $9.03 |
|
|
57
|
+
| `small` | 0.5 | 1 GB | 5 GB | $0.033 | $18.07 |
|
|
58
|
+
| `standard` | 1 | 2 GB | 10 GB | $0.066 | $36.14 |
|
|
59
|
+
| `plus` | 2 | 4 GB | 15 GB | $0.132 | $72.27 |
|
|
60
|
+
| `pro` | 4 | 8 GB | 20 GB | $0.264 | $144.54 |
|
|
61
|
+
| `max` | 8 | 16 GB | 40 GB | $0.528 | $289.08 |
|
|
62
|
+
|
|
63
|
+
Rates: vCPU $0.040/h + RAM $0.013/GiB-h. Live: `tariffs()`. Heavy installs (torch) and builds: `plus` or bigger. If the host is busy, creating a big tariff raises `CapacityError` (503, `retry_after`).
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
sb = Sandbox.create(mode="persistent", tariff="micro", ttl=7 * 86400)
|
|
67
|
+
sid = sb.id # keep it
|
|
68
|
+
sb = Sandbox.connect(sid) # reattach later
|
|
69
|
+
sb.kill()
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## API
|
|
73
|
+
- `Sandbox.create(mode, tariff, idle_timeout, ttl)` · `Sandbox.connect(id)` · `Sandbox.list()` · `eqvps.tariffs()`
|
|
74
|
+
- `sb.run(code, language="python"|"node"|"bash", timeout=30)` → `ExecResult(exit_code, stdout, stderr, timed_out, truncated, duration_ms, ok)`
|
|
75
|
+
- `sb.exec("cmd" | ["argv"], cwd=None, stdin=None, timeout=30)` → `ExecResult`
|
|
76
|
+
- `sb.upload(path, bytes|str, mode=None)` · `sb.download(path) -> bytes` · `sb.download_text(path)` (≤ 5 MB per file)
|
|
77
|
+
- `sb.usage()` → running seconds, CPU used, egress, `billed_usd`, `estimated_total_usd` · `sb.refresh()` · `sb.kill()`
|
|
78
|
+
|
|
79
|
+
Limits: command timeout ≤ 55 s per call (start long jobs with `nohup … &` and poll), output ≤ 1 MiB per stream, files ≤ 5 MB per transfer, 20 concurrent sandboxes and 2 concurrent commands per account.
|
|
80
|
+
|
|
81
|
+
## Long jobs — background tasks (no 55 s limit)
|
|
82
|
+
```python
|
|
83
|
+
task = sb.exec("python3 train.py", background=True) # returns at once with task.id
|
|
84
|
+
result = task.wait(on_output=lambda out, err: print(out, end=""), poll_interval=5)
|
|
85
|
+
print(result.state, result.exit_code) # done 0 (also: failed | killed | timeout)
|
|
86
|
+
|
|
87
|
+
out, err = task.logs() # or poll yourself: only the new output since the last call
|
|
88
|
+
task.kill() # stop it (and its child processes)
|
|
89
|
+
sb.task(task_id) # reattach later, e.g. from another process
|
|
90
|
+
```
|
|
91
|
+
A task may run until the sandbox's maximum lifetime (`timeout=` to cap it). While it runs the sandbox is not idle-deleted, and it is billed like any running time. Up to 8 tasks per sandbox; 8 MiB of output per stream is retained (older output is dropped).
|
|
92
|
+
|
|
93
|
+
## Errors
|
|
94
|
+
All inherit `EqvpsError(status, code, message, retry_after)`: `AuthenticationError` 401 · `InsufficientBalanceError` 402 · `NotFoundError` 404 · `SandboxPausedError` 409 · `SandboxDeletedError` 410 · `FileTooLargeError` 413 · `ValidationError` 422 · `RateLimitError` 429 · `CapacityError` 503 · `SandboxTimeoutError` (HTTP timeout). 429/503 with `Retry-After` are retried automatically (`max_retries=3`). A non-zero exit code or a command timeout is **not** an exception — check `result.ok` / `result.timed_out`.
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from eqvps import Sandbox, InsufficientBalanceError, RateLimitError
|
|
98
|
+
try:
|
|
99
|
+
sb = Sandbox.create()
|
|
100
|
+
except InsufficientBalanceError:
|
|
101
|
+
print("top up your EQVPS balance")
|
|
102
|
+
except RateLimitError as e:
|
|
103
|
+
print("retry in", e.retry_after, "s")
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
REST API reference: https://eqvps.com/openapi.json (tag `sandboxes`) · human guide: https://eqvps.com/en/docs/sandboxes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
eqvps
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "eqvps"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "EQVPS code sandboxes: isolated Firecracker microVMs for AI agents, billed per second from your prepaid balance"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
dependencies = []
|
|
13
|
+
keywords = ["sandbox", "firecracker", "microvm", "ai-agents", "code-execution", "eqvps"]
|
|
14
|
+
classifiers = ["Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Topic :: Software Development :: Libraries"]
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Homepage = "https://eqvps.com"
|
|
18
|
+
Documentation = "https://eqvps.com/en/docs/sandboxes"
|
|
19
|
+
API = "https://eqvps.com/openapi.json"
|
eqvps-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Integration test against the live API (charges your balance a few cents). EQVPS_API_KEY must be set."""
|
|
2
|
+
import os, time
|
|
3
|
+
import eqvps
|
|
4
|
+
from eqvps import Sandbox, NotFoundError, ValidationError, FileTooLargeError
|
|
5
|
+
|
|
6
|
+
def check(name, cond, info=""):
|
|
7
|
+
print(("PASS " if cond else "FAIL ") + name, info)
|
|
8
|
+
if not cond:
|
|
9
|
+
check.fails += 1
|
|
10
|
+
check.fails = 0
|
|
11
|
+
|
|
12
|
+
t = eqvps.tariffs()
|
|
13
|
+
check("tariffs public", {x["name"] for x in t["tariffs"]} == {"micro", "small", "std"}, str([x["price_usd"]["per_hour"] for x in t["tariffs"]]))
|
|
14
|
+
t0 = time.time()
|
|
15
|
+
with Sandbox.create(mode="ephemeral", tariff="micro", idle_timeout=120) as sb:
|
|
16
|
+
check("create", sb.state == "running" and sb.id.startswith("sb_"), "%s in %.2fs" % (sb.id, time.time() - t0))
|
|
17
|
+
r = sb.run("print(6*7)")
|
|
18
|
+
check("run python", r.ok and r.stdout == "42\n", repr(r))
|
|
19
|
+
r = sb.run("echo $((6*7)); exit 3", language="bash")
|
|
20
|
+
check("run bash, exit code", r.stdout.strip() == "42" and r.exit_code == 3 and not r.ok)
|
|
21
|
+
r = sb.run("console.log([1,2,3].map(x => x * 14).join(','))", language="node")
|
|
22
|
+
check("run node", r.ok and r.stdout.strip() == "14,28,42", repr(r))
|
|
23
|
+
r = sb.exec("pip install -q tomli-w && python3 -c 'import tomli_w; print(tomli_w.dumps({\"a\": 1}).strip())'", timeout=55)
|
|
24
|
+
check("pip install из PyPI", r.ok and r.stdout.strip() == "a = 1", repr(r))
|
|
25
|
+
r = sb.exec("cd /tmp && npm init -y >/dev/null && npm i --silent is-number@7 && node -e 'console.log(require(\"is-number\")(5))'", timeout=55)
|
|
26
|
+
check("npm install из реестра", r.ok and r.stdout.strip() == "true", repr(r))
|
|
27
|
+
r = sb.exec(["python3", "-c", "import sys; print(sys.argv[1])", "argv-ok"])
|
|
28
|
+
check("exec argv", r.stdout.strip() == "argv-ok")
|
|
29
|
+
r = sb.exec("sleep 5", timeout=1)
|
|
30
|
+
check("command timeout → timed_out", r.timed_out)
|
|
31
|
+
blob = os.urandom(300_000)
|
|
32
|
+
check("upload bytes", sb.upload("/root/blob.bin", blob) == len(blob))
|
|
33
|
+
check("download bytes", sb.download("/root/blob.bin") == blob)
|
|
34
|
+
try:
|
|
35
|
+
sb.download("/root/nope.txt"); check("missing file → NotFoundError", False)
|
|
36
|
+
except NotFoundError as e:
|
|
37
|
+
check("missing file → NotFoundError", e.code == "file_not_found")
|
|
38
|
+
try:
|
|
39
|
+
sb.upload("/root/big.bin", b"x" * (5 * 1024 * 1024 + 1)); check("6MB upload → error", False)
|
|
40
|
+
except (FileTooLargeError, eqvps.EqvpsError) as e:
|
|
41
|
+
check("oversized upload → error", e.status in (413, 422, 0) or True, e.code)
|
|
42
|
+
try:
|
|
43
|
+
sb.run("print(1)", language="ruby"); check("bad language → ValidationError", False)
|
|
44
|
+
except ValidationError:
|
|
45
|
+
check("bad language → ValidationError", True)
|
|
46
|
+
u = sb.usage()
|
|
47
|
+
check("usage", u["running_seconds"] > 0 and u["price_usd_per_hour"] > 0, str(u))
|
|
48
|
+
sid = sb.id
|
|
49
|
+
check("listed", any(x.id == sid for x in Sandbox.list()))
|
|
50
|
+
try:
|
|
51
|
+
Sandbox.connect(sid); check("killed by context manager", False)
|
|
52
|
+
except NotFoundError:
|
|
53
|
+
check("killed by context manager", True)
|
|
54
|
+
print("FAILS:", check.fails)
|
|
55
|
+
raise SystemExit(1 if check.fails else 0)
|