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 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)
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any