everpod 0.1.0__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.
everpod/__init__.py ADDED
@@ -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
+ ]
everpod/client.py ADDED
@@ -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
+ )
everpod/errors.py ADDED
@@ -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
+ )
everpod/models.py ADDED
@@ -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,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).
@@ -0,0 +1,7 @@
1
+ everpod/__init__.py,sha256=xSBXApThCdJqeT4zlXAKluR0dmdBwPi6MuexB7LstRc,981
2
+ everpod/client.py,sha256=Pn-1SJUMsVcTW1pl3M_KmzmSPMWg8eEIzJjkACNIBx4,6698
3
+ everpod/errors.py,sha256=1DxWI33ceoXVbwfA3jB4P9_YF9dJBjlcuqGOOx5brqM,1594
4
+ everpod/models.py,sha256=OOp3-lrxZRGe_-SpW4lVMDV_0dnWKYr9plh4hs6YaEA,3728
5
+ everpod-0.1.0.dist-info/METADATA,sha256=1bAjFj9XRmezLk9XMM_W7xrP25WNEfZXZIv5KPvgXDI,1924
6
+ everpod-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
7
+ everpod-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any