everpod 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.
- everpod-0.1.0/.gitignore +39 -0
- everpod-0.1.0/PKG-INFO +64 -0
- everpod-0.1.0/README.md +45 -0
- everpod-0.1.0/everpod/__init__.py +43 -0
- everpod-0.1.0/everpod/client.py +202 -0
- everpod-0.1.0/everpod/errors.py +49 -0
- everpod-0.1.0/everpod/models.py +133 -0
- everpod-0.1.0/pyproject.toml +29 -0
everpod-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Secrets — never committed. Real values live in .env (copy from .env.example).
|
|
2
|
+
.env
|
|
3
|
+
.env.*
|
|
4
|
+
!.env.example
|
|
5
|
+
*.pem
|
|
6
|
+
*.key
|
|
7
|
+
|
|
8
|
+
# Python
|
|
9
|
+
__pycache__/
|
|
10
|
+
*.pyc
|
|
11
|
+
.venv/
|
|
12
|
+
.mypy_cache/
|
|
13
|
+
.ruff_cache/
|
|
14
|
+
.pytest_cache/
|
|
15
|
+
.coverage
|
|
16
|
+
|
|
17
|
+
# Node / frontend (dashboard, later)
|
|
18
|
+
node_modules/
|
|
19
|
+
|
|
20
|
+
# CDK / build
|
|
21
|
+
cdk.out/
|
|
22
|
+
dist/
|
|
23
|
+
build/
|
|
24
|
+
|
|
25
|
+
# OS / editor
|
|
26
|
+
.DS_Store
|
|
27
|
+
|
|
28
|
+
# local secrets dir
|
|
29
|
+
secrets/
|
|
30
|
+
test-one*.txt
|
|
31
|
+
|
|
32
|
+
# AWS credential exports (never commit)
|
|
33
|
+
*accessKeys*.csv
|
|
34
|
+
*credentials*.csv
|
|
35
|
+
dashboard/*/node_modules/
|
|
36
|
+
dashboard/*/.next/
|
|
37
|
+
dashboard/*/out/
|
|
38
|
+
dashboard/*/next-env.d.ts
|
|
39
|
+
dashboard/*/.env.local
|
everpod-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: everpod
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for Everpod — GPU inference on Household AI Infrastructure
|
|
5
|
+
Project-URL: Homepage, https://everpod.io
|
|
6
|
+
Project-URL: Documentation, https://api.everpod.io/docs
|
|
7
|
+
Project-URL: Console, https://app.everpod.io
|
|
8
|
+
Author-email: Everpod <support@everpod.io>
|
|
9
|
+
License: Apache-2.0
|
|
10
|
+
Keywords: ai,everpod,gpu,image-generation,inference
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Requires-Dist: httpx>=0.24
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# everpod
|
|
21
|
+
|
|
22
|
+
Python client for [Everpod](https://everpod.io) — GPU inference on Household AI Infrastructure.
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install everpod
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from everpod import Everpod
|
|
30
|
+
|
|
31
|
+
client = Everpod(api_key="evp_...") # or set EVERPOD_API_KEY
|
|
32
|
+
|
|
33
|
+
image = client.images.generate(prompt="a lighthouse at dusk, cinematic")
|
|
34
|
+
image.save("lighthouse.png")
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Generation runs as a **job** on a real GPU node; `generate()` polls to
|
|
38
|
+
completion for you. For async control:
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
job = client.images.submit(prompt="a lighthouse at dusk")
|
|
42
|
+
job = client.jobs.wait(job.id, timeout=120)
|
|
43
|
+
print(job.artifacts[0].url)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Check live capacity before submitting — models are only offered while a node
|
|
47
|
+
can serve them:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
for model in client.models.available():
|
|
51
|
+
print(model.name, model.ready_workers)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Your metered usage (nothing is billed during the pilot):
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
usage = client.usage.get(days=30)
|
|
58
|
+
print(usage.total_jobs, usage.total_gpu_seconds)
|
|
59
|
+
for row in usage.recent:
|
|
60
|
+
print(row["job_id"], row["gpu_seconds"])
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Get an API key at [app.everpod.io/keys](https://app.everpod.io/keys/) ·
|
|
64
|
+
API reference at [api.everpod.io/docs](https://api.everpod.io/docs).
|
everpod-0.1.0/README.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# everpod
|
|
2
|
+
|
|
3
|
+
Python client for [Everpod](https://everpod.io) — GPU inference on Household AI Infrastructure.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install everpod
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from everpod import Everpod
|
|
11
|
+
|
|
12
|
+
client = Everpod(api_key="evp_...") # or set EVERPOD_API_KEY
|
|
13
|
+
|
|
14
|
+
image = client.images.generate(prompt="a lighthouse at dusk, cinematic")
|
|
15
|
+
image.save("lighthouse.png")
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Generation runs as a **job** on a real GPU node; `generate()` polls to
|
|
19
|
+
completion for you. For async control:
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
job = client.images.submit(prompt="a lighthouse at dusk")
|
|
23
|
+
job = client.jobs.wait(job.id, timeout=120)
|
|
24
|
+
print(job.artifacts[0].url)
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Check live capacity before submitting — models are only offered while a node
|
|
28
|
+
can serve them:
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
for model in client.models.available():
|
|
32
|
+
print(model.name, model.ready_workers)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Your metered usage (nothing is billed during the pilot):
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
usage = client.usage.get(days=30)
|
|
39
|
+
print(usage.total_jobs, usage.total_gpu_seconds)
|
|
40
|
+
for row in usage.recent:
|
|
41
|
+
print(row["job_id"], row["gpu_seconds"])
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Get an API key at [app.everpod.io/keys](https://app.everpod.io/keys/) ·
|
|
45
|
+
API reference at [api.everpod.io/docs](https://api.everpod.io/docs).
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Everpod — Household AI Infrastructure.
|
|
2
|
+
|
|
3
|
+
from everpod import Everpod
|
|
4
|
+
|
|
5
|
+
client = Everpod(api_key="evp_...")
|
|
6
|
+
image = client.images.generate(prompt="a lighthouse at dusk")
|
|
7
|
+
image.save("lighthouse.png")
|
|
8
|
+
|
|
9
|
+
Generation is a *job* on a real GPU in a real home, so it is asynchronous by
|
|
10
|
+
nature. ``generate()`` hides that by polling to completion; use
|
|
11
|
+
``images.submit()`` when you want the job id back immediately.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from everpod.client import Everpod
|
|
17
|
+
from everpod.errors import (
|
|
18
|
+
EverpodError,
|
|
19
|
+
JobFailedError,
|
|
20
|
+
NoCapacityError,
|
|
21
|
+
RateLimitError,
|
|
22
|
+
TimeoutError,
|
|
23
|
+
UnauthorizedError,
|
|
24
|
+
)
|
|
25
|
+
from everpod.models import Artifact, Image, Job, Model, Usage
|
|
26
|
+
|
|
27
|
+
__version__ = "0.1.0"
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"Artifact",
|
|
31
|
+
"Everpod",
|
|
32
|
+
"EverpodError",
|
|
33
|
+
"Image",
|
|
34
|
+
"Job",
|
|
35
|
+
"JobFailedError",
|
|
36
|
+
"Model",
|
|
37
|
+
"NoCapacityError",
|
|
38
|
+
"RateLimitError",
|
|
39
|
+
"TimeoutError",
|
|
40
|
+
"UnauthorizedError",
|
|
41
|
+
"Usage",
|
|
42
|
+
"__version__",
|
|
43
|
+
]
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""The Everpod client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from everpod.errors import (
|
|
12
|
+
EverpodError,
|
|
13
|
+
JobFailedError,
|
|
14
|
+
NoCapacityError,
|
|
15
|
+
RateLimitError,
|
|
16
|
+
TimeoutError,
|
|
17
|
+
UnauthorizedError,
|
|
18
|
+
)
|
|
19
|
+
from everpod.models import Image, Job, Model, Usage
|
|
20
|
+
|
|
21
|
+
DEFAULT_BASE_URL = "https://api.everpod.io"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Everpod:
|
|
25
|
+
"""Client for the Everpod API.
|
|
26
|
+
|
|
27
|
+
``api_key`` falls back to the ``EVERPOD_API_KEY`` environment variable, so
|
|
28
|
+
scripts and notebooks need no literal key in source.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
api_key: str | None = None,
|
|
34
|
+
*,
|
|
35
|
+
base_url: str | None = None,
|
|
36
|
+
timeout: float = 30.0,
|
|
37
|
+
) -> None:
|
|
38
|
+
key = api_key or os.environ.get("EVERPOD_API_KEY")
|
|
39
|
+
if not key:
|
|
40
|
+
raise UnauthorizedError(
|
|
41
|
+
"no API key: pass api_key=... or set EVERPOD_API_KEY "
|
|
42
|
+
"(create one at https://app.everpod.io/keys/)"
|
|
43
|
+
)
|
|
44
|
+
self._base_url = (base_url or os.environ.get("EVERPOD_BASE_URL") or DEFAULT_BASE_URL).rstrip(
|
|
45
|
+
"/"
|
|
46
|
+
)
|
|
47
|
+
self._client = httpx.Client(
|
|
48
|
+
base_url=self._base_url,
|
|
49
|
+
timeout=timeout,
|
|
50
|
+
headers={
|
|
51
|
+
"Authorization": f"Bearer {key}",
|
|
52
|
+
"User-Agent": "everpod-python/0.1.0",
|
|
53
|
+
},
|
|
54
|
+
)
|
|
55
|
+
self.images = _Images(self)
|
|
56
|
+
self.jobs = _Jobs(self)
|
|
57
|
+
self.models = _Models(self)
|
|
58
|
+
self.usage = _Usage(self)
|
|
59
|
+
|
|
60
|
+
# ------------------------------------------------------------- transport
|
|
61
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
|
|
62
|
+
try:
|
|
63
|
+
response = self._client.request(method, path, **kwargs)
|
|
64
|
+
except httpx.HTTPError as exc:
|
|
65
|
+
raise EverpodError(f"network error talking to {self._base_url}: {exc}") from exc
|
|
66
|
+
|
|
67
|
+
if response.status_code in (401, 403):
|
|
68
|
+
raise UnauthorizedError("API key rejected — is it revoked? (see /keys)")
|
|
69
|
+
if response.status_code == 429:
|
|
70
|
+
raise RateLimitError("rate limited — slow down and retry")
|
|
71
|
+
if response.status_code >= 400:
|
|
72
|
+
detail = ""
|
|
73
|
+
try:
|
|
74
|
+
detail = response.json().get("detail", "")
|
|
75
|
+
except Exception: # noqa: BLE001 - body may not be JSON
|
|
76
|
+
detail = response.text[:200]
|
|
77
|
+
raise EverpodError(f"{method} {path} -> {response.status_code}: {detail}")
|
|
78
|
+
result: dict[str, Any] = response.json() if response.content else {}
|
|
79
|
+
return result
|
|
80
|
+
|
|
81
|
+
def close(self) -> None:
|
|
82
|
+
self._client.close()
|
|
83
|
+
|
|
84
|
+
def __enter__(self) -> Everpod:
|
|
85
|
+
return self
|
|
86
|
+
|
|
87
|
+
def __exit__(self, *exc: object) -> None:
|
|
88
|
+
self.close()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class _Images:
|
|
92
|
+
def __init__(self, client: Everpod) -> None:
|
|
93
|
+
self._c = client
|
|
94
|
+
|
|
95
|
+
def submit(
|
|
96
|
+
self,
|
|
97
|
+
*,
|
|
98
|
+
prompt: str,
|
|
99
|
+
model: str = "z-image-turbo",
|
|
100
|
+
size: str | None = None,
|
|
101
|
+
seed: int | None = None,
|
|
102
|
+
idempotency_key: str | None = None,
|
|
103
|
+
) -> Job:
|
|
104
|
+
"""Queue a generation and return immediately."""
|
|
105
|
+
body: dict[str, Any] = {"prompt": prompt, "model": model}
|
|
106
|
+
if size:
|
|
107
|
+
body["size"] = size
|
|
108
|
+
if seed is not None:
|
|
109
|
+
body["seed"] = seed
|
|
110
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
111
|
+
raw = self._c._request("POST", "/v1/images/generations", json=body, headers=headers)
|
|
112
|
+
return Job._from_api(raw)
|
|
113
|
+
|
|
114
|
+
def generate(
|
|
115
|
+
self,
|
|
116
|
+
*,
|
|
117
|
+
prompt: str,
|
|
118
|
+
model: str = "z-image-turbo",
|
|
119
|
+
size: str | None = None,
|
|
120
|
+
seed: int | None = None,
|
|
121
|
+
timeout: float = 600.0,
|
|
122
|
+
poll_interval: float = 1.5,
|
|
123
|
+
idempotency_key: str | None = None,
|
|
124
|
+
) -> Image:
|
|
125
|
+
"""Queue a generation and block until it finishes.
|
|
126
|
+
|
|
127
|
+
Raises :class:`JobFailedError` if it ends badly and
|
|
128
|
+
:class:`TimeoutError` if ``timeout`` elapses first (the job keeps
|
|
129
|
+
running server-side either way).
|
|
130
|
+
"""
|
|
131
|
+
job = self.submit(
|
|
132
|
+
prompt=prompt,
|
|
133
|
+
model=model,
|
|
134
|
+
size=size,
|
|
135
|
+
seed=seed,
|
|
136
|
+
idempotency_key=idempotency_key,
|
|
137
|
+
)
|
|
138
|
+
finished = self._c.jobs.wait(job.id, timeout=timeout, poll_interval=poll_interval)
|
|
139
|
+
if not finished.succeeded:
|
|
140
|
+
raise JobFailedError(finished.id, finished.status, finished.failure_reason)
|
|
141
|
+
if not finished.artifacts:
|
|
142
|
+
raise EverpodError(f"job {finished.id} succeeded with no artifact")
|
|
143
|
+
return Image(job=finished)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class _Jobs:
|
|
147
|
+
def __init__(self, client: Everpod) -> None:
|
|
148
|
+
self._c = client
|
|
149
|
+
|
|
150
|
+
def retrieve(self, job_id: str) -> Job:
|
|
151
|
+
return Job._from_api(self._c._request("GET", f"/v1/jobs/{job_id}"))
|
|
152
|
+
|
|
153
|
+
def cancel(self, job_id: str) -> Job:
|
|
154
|
+
return Job._from_api(self._c._request("POST", f"/v1/jobs/{job_id}/cancel"))
|
|
155
|
+
|
|
156
|
+
def wait(self, job_id: str, *, timeout: float = 600.0, poll_interval: float = 1.5) -> Job:
|
|
157
|
+
"""Poll until the job is terminal."""
|
|
158
|
+
deadline = time.monotonic() + timeout
|
|
159
|
+
while True:
|
|
160
|
+
job = self.retrieve(job_id)
|
|
161
|
+
if job.done:
|
|
162
|
+
return job
|
|
163
|
+
if time.monotonic() >= deadline:
|
|
164
|
+
raise TimeoutError(job_id, timeout)
|
|
165
|
+
time.sleep(poll_interval)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class _Models:
|
|
169
|
+
def __init__(self, client: Everpod) -> None:
|
|
170
|
+
self._c = client
|
|
171
|
+
|
|
172
|
+
def list(self) -> list[Model]:
|
|
173
|
+
"""Every catalog model, with LIVE capacity.
|
|
174
|
+
|
|
175
|
+
``available`` is False when no online node can currently serve it —
|
|
176
|
+
check it before submitting rather than queueing into nothing.
|
|
177
|
+
"""
|
|
178
|
+
raw = self._c._client.get("/v1/models").json()
|
|
179
|
+
return [Model._from_api(m) for m in raw]
|
|
180
|
+
|
|
181
|
+
def available(self) -> list[Model]:
|
|
182
|
+
return [m for m in self.list() if m.available]
|
|
183
|
+
|
|
184
|
+
def require(self, name: str) -> Model:
|
|
185
|
+
"""Return the model, or raise :class:`NoCapacityError` if it cannot run now."""
|
|
186
|
+
for model in self.list():
|
|
187
|
+
if model.name == name:
|
|
188
|
+
if not model.available:
|
|
189
|
+
raise NoCapacityError(f"no node is serving {name!r} right now")
|
|
190
|
+
return model
|
|
191
|
+
raise EverpodError(f"unknown model {name!r}")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class _Usage:
|
|
195
|
+
def __init__(self, client: Everpod) -> None:
|
|
196
|
+
self._c = client
|
|
197
|
+
|
|
198
|
+
def get(self, *, days: int = 30, limit: int = 50) -> Usage:
|
|
199
|
+
"""Your metered usage: totals, per-day, per-model, and recent executions."""
|
|
200
|
+
return Usage._from_api(
|
|
201
|
+
self._c._request("GET", "/v1/usage", params={"days": days, "limit": limit})
|
|
202
|
+
)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Typed errors — every failure mode a caller can reasonably branch on."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class EverpodError(Exception):
|
|
7
|
+
"""Base class for everything this library raises."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class UnauthorizedError(EverpodError):
|
|
11
|
+
"""The API key is missing, wrong, or revoked."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RateLimitError(EverpodError):
|
|
15
|
+
"""Too many requests; retry after a pause."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class NoCapacityError(EverpodError):
|
|
19
|
+
"""No node can serve this model right now.
|
|
20
|
+
|
|
21
|
+
Distinct from a failure: the request was valid, the fleet is just busy or
|
|
22
|
+
the model is not resident on any online node. Retry later, or call
|
|
23
|
+
``client.models.list()`` and pick one that reports ``available``.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class JobFailedError(EverpodError):
|
|
28
|
+
"""The job reached a terminal non-success state."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, job_id: str, status: str, reason: str | None = None) -> None:
|
|
31
|
+
self.job_id = job_id
|
|
32
|
+
self.status = status
|
|
33
|
+
self.reason = reason
|
|
34
|
+
super().__init__(f"job {job_id} {status.lower()}" + (f": {reason}" if reason else ""))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TimeoutError(EverpodError): # noqa: A001 - deliberate, mirrors the builtin name
|
|
38
|
+
"""The job did not finish within the requested timeout.
|
|
39
|
+
|
|
40
|
+
The job keeps running — poll ``client.jobs.retrieve(job_id)`` later.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, job_id: str, waited: float) -> None:
|
|
44
|
+
self.job_id = job_id
|
|
45
|
+
self.waited = waited
|
|
46
|
+
super().__init__(
|
|
47
|
+
f"job {job_id} still running after {waited:.0f}s "
|
|
48
|
+
f"(it has not been cancelled — poll jobs.retrieve to keep waiting)"
|
|
49
|
+
)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Plain dataclasses for what the API returns. No pydantic dependency."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class Artifact:
|
|
14
|
+
url: str
|
|
15
|
+
content_type: str
|
|
16
|
+
bytes: int
|
|
17
|
+
width: int | None = None
|
|
18
|
+
height: int | None = None
|
|
19
|
+
|
|
20
|
+
def read(self, *, timeout: float = 60.0) -> bytes:
|
|
21
|
+
"""Download the artifact bytes."""
|
|
22
|
+
response = httpx.get(self.url, timeout=timeout, follow_redirects=True)
|
|
23
|
+
response.raise_for_status()
|
|
24
|
+
return response.content
|
|
25
|
+
|
|
26
|
+
def save(self, path: str | Path, *, timeout: float = 60.0) -> Path:
|
|
27
|
+
"""Download and write to ``path``. Returns the path."""
|
|
28
|
+
out = Path(path)
|
|
29
|
+
out.write_bytes(self.read(timeout=timeout))
|
|
30
|
+
return out
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def _from_api(cls, raw: dict[str, Any]) -> Artifact:
|
|
34
|
+
return cls(
|
|
35
|
+
url=raw["url"],
|
|
36
|
+
content_type=raw.get("content_type", "application/octet-stream"),
|
|
37
|
+
bytes=int(raw.get("bytes", 0)),
|
|
38
|
+
width=raw.get("width"),
|
|
39
|
+
height=raw.get("height"),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class Job:
|
|
45
|
+
id: str
|
|
46
|
+
status: str
|
|
47
|
+
model: str
|
|
48
|
+
progress: float | None = None
|
|
49
|
+
failure_reason: str | None = None
|
|
50
|
+
artifacts: list[Artifact] = field(default_factory=list)
|
|
51
|
+
|
|
52
|
+
#: Statuses from which a job will never move again.
|
|
53
|
+
TERMINAL = frozenset({"SUCCEEDED", "PERMANENT_FAILED", "UNCERTAIN", "CANCELLED"})
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def done(self) -> bool:
|
|
57
|
+
return self.status in self.TERMINAL
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def succeeded(self) -> bool:
|
|
61
|
+
return self.status == "SUCCEEDED"
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def _from_api(cls, raw: dict[str, Any]) -> Job:
|
|
65
|
+
return cls(
|
|
66
|
+
id=raw["job_id"],
|
|
67
|
+
status=raw["status"],
|
|
68
|
+
model=raw.get("model", ""),
|
|
69
|
+
progress=raw.get("progress"),
|
|
70
|
+
failure_reason=raw.get("failure_reason"),
|
|
71
|
+
artifacts=[Artifact._from_api(a) for a in raw.get("artifacts") or []],
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class Image:
|
|
77
|
+
"""A finished generation: the job plus its first artifact."""
|
|
78
|
+
|
|
79
|
+
job: Job
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def url(self) -> str:
|
|
83
|
+
return self.job.artifacts[0].url
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def artifact(self) -> Artifact:
|
|
87
|
+
return self.job.artifacts[0]
|
|
88
|
+
|
|
89
|
+
def read(self, *, timeout: float = 60.0) -> bytes:
|
|
90
|
+
return self.artifact.read(timeout=timeout)
|
|
91
|
+
|
|
92
|
+
def save(self, path: str | Path, *, timeout: float = 60.0) -> Path:
|
|
93
|
+
return self.artifact.save(path, timeout=timeout)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class Model:
|
|
98
|
+
name: str
|
|
99
|
+
job_type: str
|
|
100
|
+
quantization: str
|
|
101
|
+
ready_workers: int
|
|
102
|
+
available: bool
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def _from_api(cls, raw: dict[str, Any]) -> Model:
|
|
106
|
+
return cls(
|
|
107
|
+
name=raw["model"],
|
|
108
|
+
job_type=raw.get("job_type", "IMAGE"),
|
|
109
|
+
quantization=raw.get("quantization", ""),
|
|
110
|
+
ready_workers=int(raw.get("ready_workers", 0)),
|
|
111
|
+
available=bool(raw.get("available")),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True)
|
|
116
|
+
class Usage:
|
|
117
|
+
window_days: int
|
|
118
|
+
total_jobs: int
|
|
119
|
+
total_gpu_seconds: float
|
|
120
|
+
by_day: list[dict[str, Any]]
|
|
121
|
+
by_model: list[dict[str, Any]]
|
|
122
|
+
recent: list[dict[str, Any]]
|
|
123
|
+
|
|
124
|
+
@classmethod
|
|
125
|
+
def _from_api(cls, raw: dict[str, Any]) -> Usage:
|
|
126
|
+
return cls(
|
|
127
|
+
window_days=int(raw.get("window_days", 0)),
|
|
128
|
+
total_jobs=int(raw.get("total_jobs", 0)),
|
|
129
|
+
total_gpu_seconds=float(raw.get("total_gpu_seconds", 0.0)),
|
|
130
|
+
by_day=raw.get("by_day") or [],
|
|
131
|
+
by_model=raw.get("by_model") or [],
|
|
132
|
+
recent=raw.get("recent") or [],
|
|
133
|
+
)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "everpod"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Python client for Everpod — GPU inference on Household AI Infrastructure"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.9"
|
|
7
|
+
license = { text = "Apache-2.0" }
|
|
8
|
+
authors = [{ name = "Everpod", email = "support@everpod.io" }]
|
|
9
|
+
keywords = ["everpod", "gpu", "inference", "image-generation", "ai"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 3 - Alpha",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"License :: OSI Approved :: Apache Software License",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
16
|
+
]
|
|
17
|
+
dependencies = ["httpx>=0.24"]
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://everpod.io"
|
|
21
|
+
Documentation = "https://api.everpod.io/docs"
|
|
22
|
+
Console = "https://app.everpod.io"
|
|
23
|
+
|
|
24
|
+
[build-system]
|
|
25
|
+
requires = ["hatchling"]
|
|
26
|
+
build-backend = "hatchling.build"
|
|
27
|
+
|
|
28
|
+
[tool.hatch.build.targets.wheel]
|
|
29
|
+
packages = ["everpod"]
|