agenttier 0.1.1__py3-none-any.whl
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.
- agenttier/__init__.py +83 -0
- agenttier/_http.py +74 -0
- agenttier/_version.py +11 -0
- agenttier/async_client.py +141 -0
- agenttier/async_sandbox.py +110 -0
- agenttier/auth.py +105 -0
- agenttier/client.py +175 -0
- agenttier/exceptions.py +61 -0
- agenttier/models.py +136 -0
- agenttier/py.typed +0 -0
- agenttier/sandbox.py +150 -0
- agenttier-0.1.1.dist-info/METADATA +137 -0
- agenttier-0.1.1.dist-info/RECORD +14 -0
- agenttier-0.1.1.dist-info/WHEEL +4 -0
agenttier/__init__.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Copyright 2024 AgentTier Authors.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""AgentTier Python SDK.
|
|
5
|
+
|
|
6
|
+
Manage isolated AI agent sandboxes on Kubernetes from Python.
|
|
7
|
+
|
|
8
|
+
Typical usage::
|
|
9
|
+
|
|
10
|
+
from agenttier import AgentTierClient
|
|
11
|
+
|
|
12
|
+
with AgentTierClient(api_url="https://agenttier.company.com") as client:
|
|
13
|
+
sandbox = client.create_sandbox(template="general-coding", name="demo")
|
|
14
|
+
sandbox.wait_until_running()
|
|
15
|
+
print(sandbox.exec("uname -a").stdout)
|
|
16
|
+
sandbox.terminate()
|
|
17
|
+
|
|
18
|
+
See :mod:`agenttier.async_client` for the ``async/await`` variant.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from agenttier._version import __version__
|
|
22
|
+
from agenttier.async_client import AsyncAgentTierClient
|
|
23
|
+
from agenttier.async_sandbox import AsyncSandbox
|
|
24
|
+
from agenttier.auth import APIKeyAuth, AuthProvider, BearerTokenAuth, KubeconfigAuth
|
|
25
|
+
from agenttier.client import AgentTierClient
|
|
26
|
+
from agenttier.exceptions import (
|
|
27
|
+
AgentTierError,
|
|
28
|
+
APIError,
|
|
29
|
+
AuthenticationError,
|
|
30
|
+
AuthorizationError,
|
|
31
|
+
ConflictError,
|
|
32
|
+
NotFoundError,
|
|
33
|
+
PolicyViolationError,
|
|
34
|
+
SandboxErrorState,
|
|
35
|
+
SandboxTimeoutError,
|
|
36
|
+
)
|
|
37
|
+
from agenttier.models import (
|
|
38
|
+
AuditEvent,
|
|
39
|
+
CommandResult,
|
|
40
|
+
CreatedBy,
|
|
41
|
+
CurrentUser,
|
|
42
|
+
ForwardedPort,
|
|
43
|
+
SandboxPhase,
|
|
44
|
+
SandboxSummary,
|
|
45
|
+
Template,
|
|
46
|
+
UsageAnalytics,
|
|
47
|
+
)
|
|
48
|
+
from agenttier.sandbox import Sandbox
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"__version__",
|
|
52
|
+
# Clients
|
|
53
|
+
"AgentTierClient",
|
|
54
|
+
"AsyncAgentTierClient",
|
|
55
|
+
# Handles
|
|
56
|
+
"Sandbox",
|
|
57
|
+
"AsyncSandbox",
|
|
58
|
+
# Auth
|
|
59
|
+
"AuthProvider",
|
|
60
|
+
"APIKeyAuth",
|
|
61
|
+
"BearerTokenAuth",
|
|
62
|
+
"KubeconfigAuth",
|
|
63
|
+
# Models
|
|
64
|
+
"AuditEvent",
|
|
65
|
+
"CommandResult",
|
|
66
|
+
"CreatedBy",
|
|
67
|
+
"CurrentUser",
|
|
68
|
+
"ForwardedPort",
|
|
69
|
+
"SandboxPhase",
|
|
70
|
+
"SandboxSummary",
|
|
71
|
+
"Template",
|
|
72
|
+
"UsageAnalytics",
|
|
73
|
+
# Exceptions
|
|
74
|
+
"AgentTierError",
|
|
75
|
+
"APIError",
|
|
76
|
+
"AuthenticationError",
|
|
77
|
+
"AuthorizationError",
|
|
78
|
+
"ConflictError",
|
|
79
|
+
"NotFoundError",
|
|
80
|
+
"PolicyViolationError",
|
|
81
|
+
"SandboxErrorState",
|
|
82
|
+
"SandboxTimeoutError",
|
|
83
|
+
]
|
agenttier/_http.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Copyright 2024 AgentTier Authors.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Internal HTTP utilities.
|
|
5
|
+
|
|
6
|
+
Kept private (underscore prefix) so we can change the shape freely without a
|
|
7
|
+
compat guarantee. The public clients are the only consumers.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from agenttier.exceptions import (
|
|
17
|
+
APIError,
|
|
18
|
+
AuthenticationError,
|
|
19
|
+
AuthorizationError,
|
|
20
|
+
ConflictError,
|
|
21
|
+
NotFoundError,
|
|
22
|
+
PolicyViolationError,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _decode_body(response: httpx.Response) -> Any:
|
|
27
|
+
"""Return a JSON body, a text body, or None, whichever succeeds."""
|
|
28
|
+
if not response.content:
|
|
29
|
+
return None
|
|
30
|
+
try:
|
|
31
|
+
return response.json()
|
|
32
|
+
except ValueError:
|
|
33
|
+
return response.text or None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def raise_for_status(response: httpx.Response) -> None:
|
|
37
|
+
"""Translate non-2xx responses into typed SDK exceptions.
|
|
38
|
+
|
|
39
|
+
The Router returns structured JSON error bodies (``{"error": ..., ...}``)
|
|
40
|
+
so we can offer crisp exception types without parsing error strings.
|
|
41
|
+
"""
|
|
42
|
+
if response.is_success:
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
body = _decode_body(response)
|
|
46
|
+
status = response.status_code
|
|
47
|
+
message = _extract_message(body, response.reason_phrase)
|
|
48
|
+
|
|
49
|
+
if status == 401:
|
|
50
|
+
raise AuthenticationError(message)
|
|
51
|
+
if status == 403:
|
|
52
|
+
if isinstance(body, dict) and body.get("error") == "policy_violation":
|
|
53
|
+
raise PolicyViolationError(message, list(body.get("violations", [])))
|
|
54
|
+
raise AuthorizationError(message)
|
|
55
|
+
if status == 404:
|
|
56
|
+
raise NotFoundError(message)
|
|
57
|
+
if status == 409:
|
|
58
|
+
raise ConflictError(message)
|
|
59
|
+
raise APIError(status, message, body)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _extract_message(body: Any, fallback: str) -> str:
|
|
63
|
+
if isinstance(body, dict):
|
|
64
|
+
for key in ("error", "message"):
|
|
65
|
+
v = body.get(key)
|
|
66
|
+
if isinstance(v, str) and v:
|
|
67
|
+
return v
|
|
68
|
+
if isinstance(body, str) and body:
|
|
69
|
+
return body
|
|
70
|
+
return fallback or "request failed"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def default_user_agent(version: str) -> str:
|
|
74
|
+
return f"agenttier-python-sdk/{version}"
|
agenttier/_version.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Copyright 2024 AgentTier Authors.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Single source of truth for the SDK version string.
|
|
5
|
+
|
|
6
|
+
Kept in a separate module so ``pyproject.toml`` and :mod:`agenttier` can both
|
|
7
|
+
import it without circular dependencies. Release tooling reads this file when
|
|
8
|
+
bumping versions; keep the identifier named ``__version__``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.1"
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Copyright 2024 AgentTier Authors.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Async client for the AgentTier REST API."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from types import TracebackType
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from agenttier._http import default_user_agent, raise_for_status
|
|
14
|
+
from agenttier._version import __version__
|
|
15
|
+
from agenttier.async_sandbox import AsyncSandbox
|
|
16
|
+
from agenttier.auth import AuthProvider, auto_detect_auth
|
|
17
|
+
from agenttier.models import CurrentUser, SandboxSummary, Template
|
|
18
|
+
|
|
19
|
+
_API_PREFIX = "/api/v1"
|
|
20
|
+
_DEFAULT_TIMEOUT = 30.0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AsyncAgentTierClient:
|
|
24
|
+
"""Async counterpart to :class:`AgentTierClient`."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
api_url: str,
|
|
29
|
+
auth: Optional[AuthProvider] = None,
|
|
30
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
31
|
+
*,
|
|
32
|
+
verify: bool | str = True,
|
|
33
|
+
) -> None:
|
|
34
|
+
if not api_url:
|
|
35
|
+
raise ValueError("api_url must be a non-empty string")
|
|
36
|
+
self._api_url = api_url.rstrip("/")
|
|
37
|
+
self._auth = auth or auto_detect_auth()
|
|
38
|
+
self._http = httpx.AsyncClient(
|
|
39
|
+
base_url=f"{self._api_url}{_API_PREFIX}",
|
|
40
|
+
timeout=timeout,
|
|
41
|
+
verify=verify,
|
|
42
|
+
headers={"User-Agent": default_user_agent(__version__)},
|
|
43
|
+
event_hooks={"request": [self._apply_auth]},
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
async def __aenter__(self) -> "AsyncAgentTierClient":
|
|
47
|
+
return self
|
|
48
|
+
|
|
49
|
+
async def __aexit__(
|
|
50
|
+
self,
|
|
51
|
+
exc_type: Optional[type[BaseException]],
|
|
52
|
+
exc: Optional[BaseException],
|
|
53
|
+
tb: Optional[TracebackType],
|
|
54
|
+
) -> None:
|
|
55
|
+
await self.close()
|
|
56
|
+
|
|
57
|
+
async def close(self) -> None:
|
|
58
|
+
await self._http.aclose()
|
|
59
|
+
|
|
60
|
+
async def create_sandbox(
|
|
61
|
+
self,
|
|
62
|
+
template: str,
|
|
63
|
+
name: str,
|
|
64
|
+
namespace: str = "default",
|
|
65
|
+
timeout: Optional[str] = None,
|
|
66
|
+
idle_timeout: Optional[str] = None,
|
|
67
|
+
storage_size: Optional[str] = None,
|
|
68
|
+
) -> AsyncSandbox:
|
|
69
|
+
if not template:
|
|
70
|
+
raise ValueError("template must be a non-empty string")
|
|
71
|
+
if not name:
|
|
72
|
+
raise ValueError("name must be a non-empty string")
|
|
73
|
+
|
|
74
|
+
body: dict[str, object] = {
|
|
75
|
+
"name": name,
|
|
76
|
+
"namespace": namespace,
|
|
77
|
+
"templateRef": {"name": template, "kind": "ClusterSandboxTemplate"},
|
|
78
|
+
}
|
|
79
|
+
if timeout:
|
|
80
|
+
body["timeout"] = timeout
|
|
81
|
+
if idle_timeout:
|
|
82
|
+
body["idleTimeout"] = idle_timeout
|
|
83
|
+
if storage_size:
|
|
84
|
+
body["storage"] = {"size": storage_size}
|
|
85
|
+
|
|
86
|
+
resp = await self._http.post("/sandboxes", json=body)
|
|
87
|
+
raise_for_status(resp)
|
|
88
|
+
data = resp.json()
|
|
89
|
+
return AsyncSandbox(
|
|
90
|
+
self._http,
|
|
91
|
+
data["sandboxId"],
|
|
92
|
+
data.get("name", name),
|
|
93
|
+
data.get("namespace", namespace),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
async def list_sandboxes(
|
|
97
|
+
self,
|
|
98
|
+
namespace: Optional[str] = None,
|
|
99
|
+
status: Optional[str] = None,
|
|
100
|
+
) -> list[SandboxSummary]:
|
|
101
|
+
params: dict[str, str] = {}
|
|
102
|
+
if namespace:
|
|
103
|
+
params["namespace"] = namespace
|
|
104
|
+
if status:
|
|
105
|
+
params["status"] = status
|
|
106
|
+
resp = await self._http.get("/sandboxes", params=params)
|
|
107
|
+
raise_for_status(resp)
|
|
108
|
+
return [SandboxSummary.model_validate(s) for s in (resp.json().get("sandboxes") or [])]
|
|
109
|
+
|
|
110
|
+
async def get_sandbox(self, sandbox_id: str) -> AsyncSandbox:
|
|
111
|
+
if not sandbox_id:
|
|
112
|
+
raise ValueError("sandbox_id must be a non-empty string")
|
|
113
|
+
resp = await self._http.get(f"/sandboxes/{sandbox_id}")
|
|
114
|
+
raise_for_status(resp)
|
|
115
|
+
data = resp.json()
|
|
116
|
+
return AsyncSandbox(
|
|
117
|
+
self._http,
|
|
118
|
+
data["sandboxId"],
|
|
119
|
+
data.get("name", sandbox_id),
|
|
120
|
+
data.get("namespace", "default"),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
async def list_templates(self) -> list[Template]:
|
|
124
|
+
resp = await self._http.get("/templates")
|
|
125
|
+
raise_for_status(resp)
|
|
126
|
+
return [Template.model_validate(t) for t in (resp.json().get("templates") or [])]
|
|
127
|
+
|
|
128
|
+
async def get_template(self, name: str) -> Template:
|
|
129
|
+
if not name:
|
|
130
|
+
raise ValueError("name must be a non-empty string")
|
|
131
|
+
resp = await self._http.get(f"/templates/{name}")
|
|
132
|
+
raise_for_status(resp)
|
|
133
|
+
return Template.model_validate(resp.json())
|
|
134
|
+
|
|
135
|
+
async def current_user(self) -> CurrentUser:
|
|
136
|
+
resp = await self._http.get("/user/me")
|
|
137
|
+
raise_for_status(resp)
|
|
138
|
+
return CurrentUser.model_validate(resp.json())
|
|
139
|
+
|
|
140
|
+
async def _apply_auth(self, request: httpx.Request) -> None:
|
|
141
|
+
self._auth.apply(request)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# Copyright 2024 AgentTier Authors.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Async sandbox handle mirroring :mod:`agenttier.sandbox`."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
from typing import TYPE_CHECKING, Optional
|
|
10
|
+
|
|
11
|
+
from agenttier._http import raise_for_status
|
|
12
|
+
from agenttier.exceptions import SandboxErrorState, SandboxTimeoutError
|
|
13
|
+
from agenttier.models import CommandResult, ForwardedPort, SandboxPhase, SandboxSummary
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
_DEFAULT_WAIT_TIMEOUT = 120.0
|
|
19
|
+
_DEFAULT_POLL_INTERVAL = 2.0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AsyncSandbox:
|
|
23
|
+
"""Async remote handle for a sandbox."""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
http: "httpx.AsyncClient",
|
|
28
|
+
sandbox_id: str,
|
|
29
|
+
name: str,
|
|
30
|
+
namespace: str,
|
|
31
|
+
) -> None:
|
|
32
|
+
self._http = http
|
|
33
|
+
self.id = sandbox_id
|
|
34
|
+
self.name = name
|
|
35
|
+
self.namespace = namespace
|
|
36
|
+
|
|
37
|
+
async def status(self) -> SandboxSummary:
|
|
38
|
+
resp = await self._http.get(f"/sandboxes/{self.id}")
|
|
39
|
+
raise_for_status(resp)
|
|
40
|
+
return SandboxSummary.model_validate(resp.json())
|
|
41
|
+
|
|
42
|
+
async def wait_until_running(
|
|
43
|
+
self,
|
|
44
|
+
timeout: float = _DEFAULT_WAIT_TIMEOUT,
|
|
45
|
+
poll_interval: float = _DEFAULT_POLL_INTERVAL,
|
|
46
|
+
) -> SandboxSummary:
|
|
47
|
+
loop = asyncio.get_event_loop()
|
|
48
|
+
deadline = loop.time() + timeout
|
|
49
|
+
last: Optional[SandboxSummary] = None
|
|
50
|
+
while loop.time() < deadline:
|
|
51
|
+
last = await self.status()
|
|
52
|
+
if last.phase is SandboxPhase.RUNNING:
|
|
53
|
+
return last
|
|
54
|
+
if last.phase is SandboxPhase.ERROR:
|
|
55
|
+
raise SandboxErrorState(last.message or f"sandbox {self.id} entered Error state")
|
|
56
|
+
await asyncio.sleep(poll_interval)
|
|
57
|
+
raise SandboxTimeoutError(
|
|
58
|
+
f"sandbox {self.id} did not reach Running within {timeout:.0f}s "
|
|
59
|
+
f"(last phase: {last.phase.value if last else 'unknown'})"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
async def stop(self) -> None:
|
|
63
|
+
resp = await self._http.post(f"/sandboxes/{self.id}/stop")
|
|
64
|
+
raise_for_status(resp)
|
|
65
|
+
|
|
66
|
+
async def resume(self) -> None:
|
|
67
|
+
resp = await self._http.post(f"/sandboxes/{self.id}/resume")
|
|
68
|
+
raise_for_status(resp)
|
|
69
|
+
|
|
70
|
+
async def terminate(self) -> None:
|
|
71
|
+
resp = await self._http.delete(f"/sandboxes/{self.id}")
|
|
72
|
+
raise_for_status(resp)
|
|
73
|
+
|
|
74
|
+
delete = terminate
|
|
75
|
+
|
|
76
|
+
async def exec(self, command: str, timeout: int = 30) -> CommandResult:
|
|
77
|
+
if not command:
|
|
78
|
+
raise ValueError("command must be a non-empty string")
|
|
79
|
+
if timeout <= 0:
|
|
80
|
+
raise ValueError("timeout must be > 0")
|
|
81
|
+
resp = await self._http.post(
|
|
82
|
+
f"/sandboxes/{self.id}/exec",
|
|
83
|
+
json={"command": command, "timeout": timeout},
|
|
84
|
+
timeout=timeout + 10,
|
|
85
|
+
)
|
|
86
|
+
raise_for_status(resp)
|
|
87
|
+
return CommandResult.model_validate(resp.json())
|
|
88
|
+
|
|
89
|
+
async def list_ports(self) -> list[ForwardedPort]:
|
|
90
|
+
resp = await self._http.get(f"/sandboxes/{self.id}/ports")
|
|
91
|
+
raise_for_status(resp)
|
|
92
|
+
ports = resp.json().get("ports") or []
|
|
93
|
+
return [ForwardedPort.model_validate(p) for p in ports]
|
|
94
|
+
|
|
95
|
+
async def forward_port(self, port: int, protocol: str = "http") -> ForwardedPort:
|
|
96
|
+
if not 1 <= port <= 65535:
|
|
97
|
+
raise ValueError("port must be between 1 and 65535")
|
|
98
|
+
resp = await self._http.post(
|
|
99
|
+
f"/sandboxes/{self.id}/ports",
|
|
100
|
+
json={"port": port, "protocol": protocol},
|
|
101
|
+
)
|
|
102
|
+
raise_for_status(resp)
|
|
103
|
+
return ForwardedPort.model_validate(resp.json())
|
|
104
|
+
|
|
105
|
+
async def remove_port(self, port: int) -> None:
|
|
106
|
+
resp = await self._http.delete(f"/sandboxes/{self.id}/ports/{port}")
|
|
107
|
+
raise_for_status(resp)
|
|
108
|
+
|
|
109
|
+
def __repr__(self) -> str:
|
|
110
|
+
return f"AsyncSandbox(id={self.id!r}, name={self.name!r}, namespace={self.namespace!r})"
|
agenttier/auth.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# Copyright 2024 AgentTier Authors.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Authentication providers for the AgentTier SDK.
|
|
5
|
+
|
|
6
|
+
Three concrete providers are shipped:
|
|
7
|
+
|
|
8
|
+
* :class:`APIKeyAuth` — sends ``X-API-Key``.
|
|
9
|
+
* :class:`BearerTokenAuth` — sends ``Authorization: Bearer <token>`` (OIDC JWT).
|
|
10
|
+
* :class:`KubeconfigAuth` — uses the in-cluster ServiceAccount token when
|
|
11
|
+
available; falls back to unauthenticated (the Router's dev mode accepts this).
|
|
12
|
+
|
|
13
|
+
:func:`auto_detect_auth` picks the best available provider from environment
|
|
14
|
+
variables and file system state.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
from abc import ABC, abstractmethod
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
import httpx
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AuthProvider(ABC):
|
|
28
|
+
"""Attaches credentials to outgoing HTTP requests."""
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def apply(self, request: httpx.Request) -> None:
|
|
32
|
+
"""Mutate ``request`` in place to carry credentials."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class BearerTokenAuth(AuthProvider):
|
|
36
|
+
"""Static bearer token (typically an OIDC JWT)."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, token: str) -> None:
|
|
39
|
+
if not token:
|
|
40
|
+
raise ValueError("token must be a non-empty string")
|
|
41
|
+
self._token = token
|
|
42
|
+
|
|
43
|
+
def apply(self, request: httpx.Request) -> None:
|
|
44
|
+
request.headers["Authorization"] = f"Bearer {self._token}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class APIKeyAuth(AuthProvider):
|
|
48
|
+
"""AgentTier API key."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, api_key: str) -> None:
|
|
51
|
+
if not api_key:
|
|
52
|
+
raise ValueError("api_key must be a non-empty string")
|
|
53
|
+
self._api_key = api_key
|
|
54
|
+
|
|
55
|
+
def apply(self, request: httpx.Request) -> None:
|
|
56
|
+
request.headers["X-API-Key"] = self._api_key
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# Standard in-cluster ServiceAccount token path (Kubernetes Downward API).
|
|
60
|
+
_IN_CLUSTER_TOKEN_PATH = Path("/var/run/secrets/kubernetes.io/serviceaccount/token")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class KubeconfigAuth(AuthProvider):
|
|
64
|
+
"""In-cluster ServiceAccount token, if one is mounted.
|
|
65
|
+
|
|
66
|
+
A proper kubeconfig parser is intentionally out of scope; if you need
|
|
67
|
+
kubeconfig-driven auth against a remote cluster, extract the token
|
|
68
|
+
yourself and pass it to :class:`BearerTokenAuth`.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(self, token_path: Optional[str | Path] = None) -> None:
|
|
72
|
+
path = Path(token_path) if token_path else _IN_CLUSTER_TOKEN_PATH
|
|
73
|
+
self._token: Optional[str] = None
|
|
74
|
+
if path.exists():
|
|
75
|
+
try:
|
|
76
|
+
self._token = path.read_text().strip() or None
|
|
77
|
+
except OSError:
|
|
78
|
+
# Permission problems etc. — fall back to unauthenticated.
|
|
79
|
+
self._token = None
|
|
80
|
+
|
|
81
|
+
def apply(self, request: httpx.Request) -> None:
|
|
82
|
+
if self._token:
|
|
83
|
+
request.headers["Authorization"] = f"Bearer {self._token}"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def auto_detect_auth() -> AuthProvider:
|
|
87
|
+
"""Return the best available auth provider for the current environment.
|
|
88
|
+
|
|
89
|
+
Priority order:
|
|
90
|
+
|
|
91
|
+
1. ``AGENTTIER_API_KEY``
|
|
92
|
+
2. ``AGENTTIER_TOKEN`` (bearer / OIDC JWT)
|
|
93
|
+
3. In-cluster ServiceAccount token at ``/var/run/secrets/...``
|
|
94
|
+
4. Unauthenticated — the Router accepts this only in dev mode (no OIDC
|
|
95
|
+
configured); production deployments will return 401.
|
|
96
|
+
"""
|
|
97
|
+
api_key = os.environ.get("AGENTTIER_API_KEY")
|
|
98
|
+
if api_key:
|
|
99
|
+
return APIKeyAuth(api_key)
|
|
100
|
+
|
|
101
|
+
token = os.environ.get("AGENTTIER_TOKEN")
|
|
102
|
+
if token:
|
|
103
|
+
return BearerTokenAuth(token)
|
|
104
|
+
|
|
105
|
+
return KubeconfigAuth()
|
agenttier/client.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# Copyright 2024 AgentTier Authors.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Sync client for the AgentTier REST API."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from types import TracebackType
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from agenttier._http import default_user_agent, raise_for_status
|
|
14
|
+
from agenttier._version import __version__
|
|
15
|
+
from agenttier.auth import AuthProvider, auto_detect_auth
|
|
16
|
+
from agenttier.models import CurrentUser, SandboxSummary, Template
|
|
17
|
+
from agenttier.sandbox import Sandbox
|
|
18
|
+
|
|
19
|
+
_API_PREFIX = "/api/v1"
|
|
20
|
+
_DEFAULT_TIMEOUT = 30.0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AgentTierClient:
|
|
24
|
+
"""High-level sync client for the AgentTier REST API.
|
|
25
|
+
|
|
26
|
+
Example:
|
|
27
|
+
|
|
28
|
+
.. code-block:: python
|
|
29
|
+
|
|
30
|
+
with AgentTierClient(api_url="https://agenttier.company.com") as client:
|
|
31
|
+
sandbox = client.create_sandbox(template="general-coding", name="demo")
|
|
32
|
+
sandbox.wait_until_running()
|
|
33
|
+
print(sandbox.exec("uname -a").stdout)
|
|
34
|
+
sandbox.terminate()
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
api_url: str,
|
|
40
|
+
auth: Optional[AuthProvider] = None,
|
|
41
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
42
|
+
*,
|
|
43
|
+
verify: bool | str = True,
|
|
44
|
+
) -> None:
|
|
45
|
+
if not api_url:
|
|
46
|
+
raise ValueError("api_url must be a non-empty string")
|
|
47
|
+
self._api_url = api_url.rstrip("/")
|
|
48
|
+
self._auth = auth or auto_detect_auth()
|
|
49
|
+
self._http = httpx.Client(
|
|
50
|
+
base_url=f"{self._api_url}{_API_PREFIX}",
|
|
51
|
+
timeout=timeout,
|
|
52
|
+
verify=verify,
|
|
53
|
+
headers={"User-Agent": default_user_agent(__version__)},
|
|
54
|
+
event_hooks={"request": [self._apply_auth]},
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# ------- context manager --------------------------------------------
|
|
58
|
+
|
|
59
|
+
def __enter__(self) -> "AgentTierClient":
|
|
60
|
+
return self
|
|
61
|
+
|
|
62
|
+
def __exit__(
|
|
63
|
+
self,
|
|
64
|
+
exc_type: Optional[type[BaseException]],
|
|
65
|
+
exc: Optional[BaseException],
|
|
66
|
+
tb: Optional[TracebackType],
|
|
67
|
+
) -> None:
|
|
68
|
+
self.close()
|
|
69
|
+
|
|
70
|
+
def close(self) -> None:
|
|
71
|
+
self._http.close()
|
|
72
|
+
|
|
73
|
+
# ------- sandboxes --------------------------------------------------
|
|
74
|
+
|
|
75
|
+
def create_sandbox(
|
|
76
|
+
self,
|
|
77
|
+
template: str,
|
|
78
|
+
name: str,
|
|
79
|
+
namespace: str = "default",
|
|
80
|
+
timeout: Optional[str] = None,
|
|
81
|
+
idle_timeout: Optional[str] = None,
|
|
82
|
+
storage_size: Optional[str] = None,
|
|
83
|
+
) -> Sandbox:
|
|
84
|
+
"""Create a sandbox from a ``ClusterSandboxTemplate``.
|
|
85
|
+
|
|
86
|
+
``timeout`` and ``idle_timeout`` take Go-style duration strings
|
|
87
|
+
(``"8h"``, ``"30m"``).
|
|
88
|
+
"""
|
|
89
|
+
if not template:
|
|
90
|
+
raise ValueError("template must be a non-empty string")
|
|
91
|
+
if not name:
|
|
92
|
+
raise ValueError("name must be a non-empty string")
|
|
93
|
+
|
|
94
|
+
body: dict[str, object] = {
|
|
95
|
+
"name": name,
|
|
96
|
+
"namespace": namespace,
|
|
97
|
+
"templateRef": {"name": template, "kind": "ClusterSandboxTemplate"},
|
|
98
|
+
}
|
|
99
|
+
if timeout:
|
|
100
|
+
body["timeout"] = timeout
|
|
101
|
+
if idle_timeout:
|
|
102
|
+
body["idleTimeout"] = idle_timeout
|
|
103
|
+
if storage_size:
|
|
104
|
+
body["storage"] = {"size": storage_size}
|
|
105
|
+
|
|
106
|
+
resp = self._http.post("/sandboxes", json=body)
|
|
107
|
+
raise_for_status(resp)
|
|
108
|
+
data = resp.json()
|
|
109
|
+
return Sandbox(
|
|
110
|
+
self._http,
|
|
111
|
+
data["sandboxId"],
|
|
112
|
+
data.get("name", name),
|
|
113
|
+
data.get("namespace", namespace),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def list_sandboxes(
|
|
117
|
+
self,
|
|
118
|
+
namespace: Optional[str] = None,
|
|
119
|
+
status: Optional[str] = None,
|
|
120
|
+
) -> list[SandboxSummary]:
|
|
121
|
+
"""List sandboxes visible to the caller."""
|
|
122
|
+
params: dict[str, str] = {}
|
|
123
|
+
if namespace:
|
|
124
|
+
params["namespace"] = namespace
|
|
125
|
+
if status:
|
|
126
|
+
params["status"] = status
|
|
127
|
+
|
|
128
|
+
resp = self._http.get("/sandboxes", params=params)
|
|
129
|
+
raise_for_status(resp)
|
|
130
|
+
return [SandboxSummary.model_validate(s) for s in (resp.json().get("sandboxes") or [])]
|
|
131
|
+
|
|
132
|
+
def get_sandbox(self, sandbox_id: str) -> Sandbox:
|
|
133
|
+
"""Return a handle to an existing sandbox."""
|
|
134
|
+
if not sandbox_id:
|
|
135
|
+
raise ValueError("sandbox_id must be a non-empty string")
|
|
136
|
+
resp = self._http.get(f"/sandboxes/{sandbox_id}")
|
|
137
|
+
raise_for_status(resp)
|
|
138
|
+
data = resp.json()
|
|
139
|
+
return Sandbox(
|
|
140
|
+
self._http,
|
|
141
|
+
data["sandboxId"],
|
|
142
|
+
data.get("name", sandbox_id),
|
|
143
|
+
data.get("namespace", "default"),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# ------- templates --------------------------------------------------
|
|
147
|
+
|
|
148
|
+
def list_templates(self) -> list[Template]:
|
|
149
|
+
resp = self._http.get("/templates")
|
|
150
|
+
raise_for_status(resp)
|
|
151
|
+
return [Template.model_validate(t) for t in (resp.json().get("templates") or [])]
|
|
152
|
+
|
|
153
|
+
def get_template(self, name: str) -> Template:
|
|
154
|
+
if not name:
|
|
155
|
+
raise ValueError("name must be a non-empty string")
|
|
156
|
+
resp = self._http.get(f"/templates/{name}")
|
|
157
|
+
raise_for_status(resp)
|
|
158
|
+
return Template.model_validate(resp.json())
|
|
159
|
+
|
|
160
|
+
# ------- identity ---------------------------------------------------
|
|
161
|
+
|
|
162
|
+
def current_user(self) -> CurrentUser:
|
|
163
|
+
"""Return the server's view of the caller's identity.
|
|
164
|
+
|
|
165
|
+
Uses the same logic as the Web UI — handy for verifying auth is wired
|
|
166
|
+
up correctly.
|
|
167
|
+
"""
|
|
168
|
+
resp = self._http.get("/user/me")
|
|
169
|
+
raise_for_status(resp)
|
|
170
|
+
return CurrentUser.model_validate(resp.json())
|
|
171
|
+
|
|
172
|
+
# ------- internals --------------------------------------------------
|
|
173
|
+
|
|
174
|
+
def _apply_auth(self, request: httpx.Request) -> None:
|
|
175
|
+
self._auth.apply(request)
|
agenttier/exceptions.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Copyright 2024 AgentTier Authors.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Exception hierarchy for the AgentTier SDK.
|
|
5
|
+
|
|
6
|
+
All SDK errors inherit from ``AgentTierError`` so callers can catch everything
|
|
7
|
+
with a single except-clause.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AgentTierError(Exception):
|
|
16
|
+
"""Base class for all SDK errors."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AuthenticationError(AgentTierError):
|
|
20
|
+
"""Raised when authentication fails (401)."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AuthorizationError(AgentTierError):
|
|
24
|
+
"""Raised when the caller is not permitted to perform an action (403)."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class NotFoundError(AgentTierError):
|
|
28
|
+
"""Raised when a requested resource does not exist (404)."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ConflictError(AgentTierError):
|
|
32
|
+
"""Raised when an operation conflicts with the resource's current state (409)."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class PolicyViolationError(AgentTierError):
|
|
36
|
+
"""Raised when a governance policy rejects a sandbox create.
|
|
37
|
+
|
|
38
|
+
The server returns a structured body with per-rule violation codes; those
|
|
39
|
+
are preserved on the exception so callers can inspect them programmatically.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, message: str, violations: list[dict[str, Any]]) -> None:
|
|
43
|
+
super().__init__(message)
|
|
44
|
+
self.violations = violations
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class SandboxTimeoutError(AgentTierError, TimeoutError):
|
|
48
|
+
"""Raised when ``wait_until_running`` times out."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class SandboxErrorState(AgentTierError):
|
|
52
|
+
"""Raised when a sandbox transitions to the Error phase while being awaited."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class APIError(AgentTierError):
|
|
56
|
+
"""Generic HTTP error wrapping the server's response body."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, status_code: int, message: str, body: Any = None) -> None:
|
|
59
|
+
super().__init__(f"{status_code}: {message}")
|
|
60
|
+
self.status_code = status_code
|
|
61
|
+
self.body = body
|
agenttier/models.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# Copyright 2024 AgentTier Authors.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Pydantic v2 models for AgentTier REST responses.
|
|
5
|
+
|
|
6
|
+
The Router serializes fields in camelCase; we surface them to Python users as
|
|
7
|
+
snake_case. Both names are accepted when parsing so downstream code can be
|
|
8
|
+
written either way.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from typing import Any, Optional
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _to_camel(s: str) -> str:
|
|
21
|
+
head, *tail = s.split("_")
|
|
22
|
+
return head + "".join(p.capitalize() for p in tail)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _Model(BaseModel):
|
|
26
|
+
"""Internal base with camelCase serialization and permissive extras."""
|
|
27
|
+
|
|
28
|
+
model_config = ConfigDict(
|
|
29
|
+
alias_generator=_to_camel,
|
|
30
|
+
populate_by_name=True,
|
|
31
|
+
# Router responses may grow new fields; don't break old SDK versions.
|
|
32
|
+
extra="ignore",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SandboxPhase(str, Enum):
|
|
37
|
+
"""Lifecycle phases for a sandbox."""
|
|
38
|
+
|
|
39
|
+
CREATING = "Creating"
|
|
40
|
+
RUNNING = "Running"
|
|
41
|
+
STOPPED = "Stopped"
|
|
42
|
+
ERROR = "Error"
|
|
43
|
+
DELETING = "Deleting"
|
|
44
|
+
UNKNOWN = "Unknown"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class CreatedBy(_Model):
|
|
48
|
+
"""Identity of the user that created a sandbox."""
|
|
49
|
+
|
|
50
|
+
email: Optional[str] = None
|
|
51
|
+
display_name: Optional[str] = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class SandboxSummary(_Model):
|
|
55
|
+
"""Sandbox as returned by list/get endpoints."""
|
|
56
|
+
|
|
57
|
+
sandbox_id: str = Field(alias="sandboxId")
|
|
58
|
+
name: str
|
|
59
|
+
namespace: str
|
|
60
|
+
status: str
|
|
61
|
+
pod_name: Optional[str] = Field(default=None, alias="podName")
|
|
62
|
+
pvc_name: Optional[str] = Field(default=None, alias="pvcName")
|
|
63
|
+
template_ref: Optional[str] = Field(default=None, alias="templateRef")
|
|
64
|
+
created_at: Optional[str] = Field(default=None, alias="createdAt")
|
|
65
|
+
last_activity_at: Optional[str] = Field(default=None, alias="lastActivityAt")
|
|
66
|
+
created_by: Optional[CreatedBy] = Field(default=None, alias="createdBy")
|
|
67
|
+
message: Optional[str] = None
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def phase(self) -> SandboxPhase:
|
|
71
|
+
"""Typed view of ``status``; returns ``UNKNOWN`` for unexpected values."""
|
|
72
|
+
try:
|
|
73
|
+
return SandboxPhase(self.status)
|
|
74
|
+
except ValueError:
|
|
75
|
+
return SandboxPhase.UNKNOWN
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class CommandResult(_Model):
|
|
79
|
+
"""Output of a non-interactive command run inside a sandbox."""
|
|
80
|
+
|
|
81
|
+
stdout: str
|
|
82
|
+
stderr: str
|
|
83
|
+
exit_code: int = Field(alias="exitCode")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Template(_Model):
|
|
87
|
+
"""Sandbox template as exposed by the Router templates endpoints."""
|
|
88
|
+
|
|
89
|
+
name: str
|
|
90
|
+
description: Optional[str] = None
|
|
91
|
+
image: Optional[str] = None
|
|
92
|
+
resource_version: Optional[str] = Field(default=None, alias="resourceVersion")
|
|
93
|
+
# ``spec`` is the full SandboxTemplateSpec — kept as a free-form dict so the
|
|
94
|
+
# SDK does not have to track every field as it evolves.
|
|
95
|
+
spec: Optional[dict[str, Any]] = None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class ForwardedPort(_Model):
|
|
99
|
+
"""A port exposed from the sandbox via the Router."""
|
|
100
|
+
|
|
101
|
+
port: int
|
|
102
|
+
protocol: str
|
|
103
|
+
preview_url: Optional[str] = Field(default=None, alias="previewUrl")
|
|
104
|
+
internal_url: Optional[str] = Field(default=None, alias="internalUrl")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class CurrentUser(_Model):
|
|
108
|
+
"""The authenticated identity as seen by the Router."""
|
|
109
|
+
|
|
110
|
+
sub: str
|
|
111
|
+
email: Optional[str] = None
|
|
112
|
+
name: Optional[str] = None
|
|
113
|
+
groups: list[str] = Field(default_factory=list)
|
|
114
|
+
is_admin: bool = Field(default=False, alias="isAdmin")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class UsageAnalytics(_Model):
|
|
118
|
+
"""Fleet-wide usage summary returned by /analytics/usage."""
|
|
119
|
+
|
|
120
|
+
total_sandboxes: int = Field(alias="total_sandboxes")
|
|
121
|
+
status_breakdown: dict[str, int] = Field(alias="status_breakdown")
|
|
122
|
+
template_breakdown: dict[str, int] = Field(alias="template_breakdown")
|
|
123
|
+
avg_startup_ms: int = Field(alias="avg_startup_ms")
|
|
124
|
+
startup_sample_count: int = Field(alias="startup_sample_count")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class AuditEvent(_Model):
|
|
128
|
+
"""One entry from the activity log."""
|
|
129
|
+
|
|
130
|
+
timestamp: Optional[datetime] = None
|
|
131
|
+
event_type: Optional[str] = Field(default=None, alias="eventType")
|
|
132
|
+
sandbox_id: Optional[str] = Field(default=None, alias="sandboxId")
|
|
133
|
+
sandbox_name: Optional[str] = Field(default=None, alias="sandboxName")
|
|
134
|
+
namespace: Optional[str] = None
|
|
135
|
+
user_email: Optional[str] = Field(default=None, alias="userEmail")
|
|
136
|
+
details: Optional[dict[str, Any]] = None
|
agenttier/py.typed
ADDED
|
File without changes
|
agenttier/sandbox.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# Copyright 2024 AgentTier Authors.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Sync sandbox handle.
|
|
5
|
+
|
|
6
|
+
Use :meth:`AgentTierClient.create_sandbox` / :meth:`AgentTierClient.get_sandbox`
|
|
7
|
+
to obtain instances — don't construct :class:`Sandbox` directly.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import time
|
|
13
|
+
from typing import TYPE_CHECKING, Optional
|
|
14
|
+
|
|
15
|
+
from agenttier._http import raise_for_status
|
|
16
|
+
from agenttier.exceptions import SandboxErrorState, SandboxTimeoutError
|
|
17
|
+
from agenttier.models import CommandResult, ForwardedPort, SandboxPhase, SandboxSummary
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
_DEFAULT_WAIT_TIMEOUT = 120.0
|
|
23
|
+
_DEFAULT_POLL_INTERVAL = 2.0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Sandbox:
|
|
27
|
+
"""Remote handle for a sandbox running in an AgentTier cluster."""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
http: "httpx.Client",
|
|
32
|
+
sandbox_id: str,
|
|
33
|
+
name: str,
|
|
34
|
+
namespace: str,
|
|
35
|
+
) -> None:
|
|
36
|
+
self._http = http
|
|
37
|
+
self.id = sandbox_id
|
|
38
|
+
self.name = name
|
|
39
|
+
self.namespace = namespace
|
|
40
|
+
|
|
41
|
+
# ------- state -------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
def status(self) -> SandboxSummary:
|
|
44
|
+
"""Fetch the latest status from the server."""
|
|
45
|
+
resp = self._http.get(f"/sandboxes/{self.id}")
|
|
46
|
+
raise_for_status(resp)
|
|
47
|
+
return SandboxSummary.model_validate(resp.json())
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def phase(self) -> SandboxPhase:
|
|
51
|
+
"""Shortcut returning the typed phase of the current status."""
|
|
52
|
+
return self.status().phase
|
|
53
|
+
|
|
54
|
+
def wait_until_running(
|
|
55
|
+
self,
|
|
56
|
+
timeout: float = _DEFAULT_WAIT_TIMEOUT,
|
|
57
|
+
poll_interval: float = _DEFAULT_POLL_INTERVAL,
|
|
58
|
+
) -> SandboxSummary:
|
|
59
|
+
"""Block until the sandbox reaches ``Running``.
|
|
60
|
+
|
|
61
|
+
Returns the final :class:`SandboxSummary` on success.
|
|
62
|
+
|
|
63
|
+
Raises :class:`SandboxTimeoutError` on timeout and
|
|
64
|
+
:class:`SandboxErrorState` if the sandbox transitions to Error.
|
|
65
|
+
"""
|
|
66
|
+
deadline = time.monotonic() + timeout
|
|
67
|
+
last: Optional[SandboxSummary] = None
|
|
68
|
+
while time.monotonic() < deadline:
|
|
69
|
+
last = self.status()
|
|
70
|
+
if last.phase is SandboxPhase.RUNNING:
|
|
71
|
+
return last
|
|
72
|
+
if last.phase is SandboxPhase.ERROR:
|
|
73
|
+
raise SandboxErrorState(last.message or f"sandbox {self.id} entered Error state")
|
|
74
|
+
time.sleep(poll_interval)
|
|
75
|
+
raise SandboxTimeoutError(
|
|
76
|
+
f"sandbox {self.id} did not reach Running within {timeout:.0f}s "
|
|
77
|
+
f"(last phase: {last.phase.value if last else 'unknown'})"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# ------- lifecycle ---------------------------------------------------
|
|
81
|
+
|
|
82
|
+
def stop(self) -> None:
|
|
83
|
+
"""Delete the sandbox Pod while preserving the PVC."""
|
|
84
|
+
resp = self._http.post(f"/sandboxes/{self.id}/stop")
|
|
85
|
+
raise_for_status(resp)
|
|
86
|
+
|
|
87
|
+
def resume(self) -> None:
|
|
88
|
+
"""Re-create the Pod for a stopped sandbox; re-uses the same PVC."""
|
|
89
|
+
resp = self._http.post(f"/sandboxes/{self.id}/resume")
|
|
90
|
+
raise_for_status(resp)
|
|
91
|
+
|
|
92
|
+
def terminate(self) -> None:
|
|
93
|
+
"""Permanently delete the sandbox and its workspace."""
|
|
94
|
+
resp = self._http.delete(f"/sandboxes/{self.id}")
|
|
95
|
+
raise_for_status(resp)
|
|
96
|
+
|
|
97
|
+
# Alias kept for consistency with the REST name.
|
|
98
|
+
delete = terminate
|
|
99
|
+
|
|
100
|
+
# ------- execution ---------------------------------------------------
|
|
101
|
+
|
|
102
|
+
def exec(self, command: str, timeout: int = 30) -> CommandResult:
|
|
103
|
+
"""Run a shell command inside the sandbox and wait for the result.
|
|
104
|
+
|
|
105
|
+
``timeout`` is applied both server-side (to the exec) and on the HTTP
|
|
106
|
+
call (with a small buffer for overhead).
|
|
107
|
+
"""
|
|
108
|
+
if not command:
|
|
109
|
+
raise ValueError("command must be a non-empty string")
|
|
110
|
+
if timeout <= 0:
|
|
111
|
+
raise ValueError("timeout must be > 0")
|
|
112
|
+
# Give the HTTP call a small buffer over the server-side exec timeout
|
|
113
|
+
# so the server error bubbles up instead of httpx cutting us off.
|
|
114
|
+
resp = self._http.post(
|
|
115
|
+
f"/sandboxes/{self.id}/exec",
|
|
116
|
+
json={"command": command, "timeout": timeout},
|
|
117
|
+
timeout=timeout + 10,
|
|
118
|
+
)
|
|
119
|
+
raise_for_status(resp)
|
|
120
|
+
return CommandResult.model_validate(resp.json())
|
|
121
|
+
|
|
122
|
+
# ------- port forwarding --------------------------------------------
|
|
123
|
+
|
|
124
|
+
def list_ports(self) -> list[ForwardedPort]:
|
|
125
|
+
"""Return the ports currently forwarded from this sandbox."""
|
|
126
|
+
resp = self._http.get(f"/sandboxes/{self.id}/ports")
|
|
127
|
+
raise_for_status(resp)
|
|
128
|
+
ports = resp.json().get("ports") or []
|
|
129
|
+
return [ForwardedPort.model_validate(p) for p in ports]
|
|
130
|
+
|
|
131
|
+
def forward_port(self, port: int, protocol: str = "http") -> ForwardedPort:
|
|
132
|
+
"""Expose a container port via a ClusterIP Service (and Ingress if configured)."""
|
|
133
|
+
if not 1 <= port <= 65535:
|
|
134
|
+
raise ValueError("port must be between 1 and 65535")
|
|
135
|
+
resp = self._http.post(
|
|
136
|
+
f"/sandboxes/{self.id}/ports",
|
|
137
|
+
json={"port": port, "protocol": protocol},
|
|
138
|
+
)
|
|
139
|
+
raise_for_status(resp)
|
|
140
|
+
return ForwardedPort.model_validate(resp.json())
|
|
141
|
+
|
|
142
|
+
def remove_port(self, port: int) -> None:
|
|
143
|
+
"""Tear down a previously-forwarded port."""
|
|
144
|
+
resp = self._http.delete(f"/sandboxes/{self.id}/ports/{port}")
|
|
145
|
+
raise_for_status(resp)
|
|
146
|
+
|
|
147
|
+
# ------- misc --------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
def __repr__(self) -> str:
|
|
150
|
+
return f"Sandbox(id={self.id!r}, name={self.name!r}, namespace={self.namespace!r})"
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agenttier
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Python SDK for AgentTier — manage isolated AI agent sandboxes on Kubernetes
|
|
5
|
+
Project-URL: Homepage, https://github.com/agenttier/agenttier
|
|
6
|
+
Project-URL: Documentation, https://agenttier.github.io/agenttier/sdk/
|
|
7
|
+
Project-URL: Repository, https://github.com/agenttier/agenttier
|
|
8
|
+
Project-URL: Issues, https://github.com/agenttier/agenttier/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/agenttier/agenttier/blob/main/CHANGELOG.md
|
|
10
|
+
Author: AgentTier Authors
|
|
11
|
+
License-Expression: Apache-2.0
|
|
12
|
+
Keywords: agents,ai-agents,developer-tools,kubernetes,sandbox
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
23
|
+
Classifier: Topic :: System :: Clustering
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Requires-Dist: httpx<1.0,>=0.27.0
|
|
27
|
+
Requires-Dist: pydantic<3.0,>=2.0.0
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest-httpx>=0.30; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
33
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# AgentTier Python SDK
|
|
37
|
+
|
|
38
|
+
Programmatic client for [AgentTier](https://github.com/agenttier/agenttier) —
|
|
39
|
+
manage isolated, persistent Kubernetes sandboxes for AI agents from Python.
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
pip install agenttier
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Synchronous quick start
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from agenttier import AgentTierClient
|
|
49
|
+
|
|
50
|
+
with AgentTierClient(api_url="https://agenttier.company.com") as client:
|
|
51
|
+
sandbox = client.create_sandbox(template="general-coding", name="demo")
|
|
52
|
+
sandbox.wait_until_running()
|
|
53
|
+
|
|
54
|
+
result = sandbox.exec("echo 'hello from AgentTier'")
|
|
55
|
+
print(result.stdout, "exit", result.exit_code)
|
|
56
|
+
|
|
57
|
+
port = sandbox.forward_port(8080)
|
|
58
|
+
print("Forwarded:", port.preview_url or port.internal_url)
|
|
59
|
+
|
|
60
|
+
sandbox.terminate()
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Async
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
import asyncio
|
|
67
|
+
from agenttier import AsyncAgentTierClient
|
|
68
|
+
|
|
69
|
+
async def main():
|
|
70
|
+
async with AsyncAgentTierClient(api_url="https://agenttier.company.com") as client:
|
|
71
|
+
sandbox = await client.create_sandbox(template="general-coding", name="demo")
|
|
72
|
+
await sandbox.wait_until_running()
|
|
73
|
+
result = await sandbox.exec("uname -a")
|
|
74
|
+
print(result.stdout)
|
|
75
|
+
await sandbox.terminate()
|
|
76
|
+
|
|
77
|
+
asyncio.run(main())
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Authentication
|
|
81
|
+
|
|
82
|
+
The SDK auto-detects credentials in this order:
|
|
83
|
+
|
|
84
|
+
1. `AGENTTIER_API_KEY` — sent as `X-API-Key`.
|
|
85
|
+
2. `AGENTTIER_TOKEN` — sent as `Authorization: Bearer <token>` (OIDC JWT).
|
|
86
|
+
3. In-cluster ServiceAccount token at `/var/run/secrets/kubernetes.io/serviceaccount/token`.
|
|
87
|
+
4. Unauthenticated (accepted only in the Router's dev mode).
|
|
88
|
+
|
|
89
|
+
Or pass an explicit provider:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from agenttier import AgentTierClient, APIKeyAuth, BearerTokenAuth
|
|
93
|
+
|
|
94
|
+
client = AgentTierClient(
|
|
95
|
+
api_url="https://agenttier.company.com",
|
|
96
|
+
auth=APIKeyAuth("sk_live_..."),
|
|
97
|
+
)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Error handling
|
|
101
|
+
|
|
102
|
+
Every error inherits from `AgentTierError` so you can catch them all at once.
|
|
103
|
+
The common subclasses you'll want to handle individually:
|
|
104
|
+
|
|
105
|
+
| Exception | When |
|
|
106
|
+
| --- | --- |
|
|
107
|
+
| `AuthenticationError` | 401 — token / API key missing or invalid |
|
|
108
|
+
| `AuthorizationError` | 403 — authenticated but not permitted |
|
|
109
|
+
| `PolicyViolationError` | 403 with governance body; exposes `.violations` |
|
|
110
|
+
| `NotFoundError` | 404 — resource doesn't exist |
|
|
111
|
+
| `ConflictError` | 409 — operation invalid for current state |
|
|
112
|
+
| `SandboxTimeoutError` | `wait_until_running` timed out |
|
|
113
|
+
| `SandboxErrorState` | sandbox entered the `Error` phase while waiting |
|
|
114
|
+
| `APIError` | anything else; carries `.status_code` and `.body` |
|
|
115
|
+
|
|
116
|
+
## Supported API surface (v0.1.1)
|
|
117
|
+
|
|
118
|
+
Only endpoints that the Router server implements in v0.1.0 are exposed:
|
|
119
|
+
|
|
120
|
+
- **Sandboxes** — `create_sandbox`, `list_sandboxes`, `get_sandbox`, `stop`,
|
|
121
|
+
`resume`, `terminate`, `exec`, `wait_until_running`, `status`.
|
|
122
|
+
- **Port forwarding** — `forward_port`, `list_ports`, `remove_port`.
|
|
123
|
+
- **Templates** — `list_templates`, `get_template`.
|
|
124
|
+
- **Identity** — `current_user`.
|
|
125
|
+
|
|
126
|
+
Endpoints that are not yet implemented on the server (file transfer, sharing,
|
|
127
|
+
cloning, WebSocket terminal from Python) are **not exposed** by the SDK and
|
|
128
|
+
will be added in a future release once the server ships them.
|
|
129
|
+
|
|
130
|
+
## Supported Python versions
|
|
131
|
+
|
|
132
|
+
3.10, 3.11, 3.12, 3.13. Runtime dependencies: `httpx` and `pydantic`.
|
|
133
|
+
|
|
134
|
+
## License
|
|
135
|
+
|
|
136
|
+
Apache-2.0. Source at
|
|
137
|
+
[github.com/agenttier/agenttier/tree/main/python-sdk](https://github.com/agenttier/agenttier/tree/main/python-sdk).
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
agenttier/__init__.py,sha256=C7c0mu17A3NqYJ5_pzth4FG7XWdWr2b2uvisDTMtkn8,1966
|
|
2
|
+
agenttier/_http.py,sha256=JUv82WECWrZidSIq-Xt7r-Mp9CurTLBE7mn3-6o5NOo,2079
|
|
3
|
+
agenttier/_version.py,sha256=umoBVGIuorxMM9hvvs2JqoAPSiQd3WaFR9u-MKvTfZU,374
|
|
4
|
+
agenttier/async_client.py,sha256=0C1L6lMzpBwWX46Ymb5eNWJdltuIuzoi5HYST5nVb5Y,4594
|
|
5
|
+
agenttier/async_sandbox.py,sha256=TZIzYY4PC4PgNtn7oj-aEetRo2h_5_pi-0sFFGePpOs,3854
|
|
6
|
+
agenttier/auth.py,sha256=9cPUq7ZVBQT49ybU-2dm536u7obKA_80g1seIkmQ_WQ,3364
|
|
7
|
+
agenttier/client.py,sha256=2NfGTP6XgE0MhUE9wWeMSaGegkHkAS6z6kROhEWQo0s,5620
|
|
8
|
+
agenttier/exceptions.py,sha256=NDCgXccXvwe37ONSIq-pxNJovmAclCAIIb5fCrhNmPY,1790
|
|
9
|
+
agenttier/models.py,sha256=6nqPYZI3BJVnNHxACVj-0_SXjVtJeVGBSiia7F8C-fg,4279
|
|
10
|
+
agenttier/sandbox.py,sha256=4qwWgoIyCkJlm04WoYZ10sEpdGnal8bRLBf62j-MSD0,5528
|
|
11
|
+
agenttier/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
agenttier-0.1.1.dist-info/METADATA,sha256=TokHeeXQgigqyH0sQG-po-O_eqztNDGy5MNTKpF4Pi0,4908
|
|
13
|
+
agenttier-0.1.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
14
|
+
agenttier-0.1.1.dist-info/RECORD,,
|