Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ pv.images.upload(url="https://example.com/a.png", expires_in=3600)

With no API key at all, `images.upload(file)` makes an anonymous upload that expires after 30 days.

Generate an image from a prompt and host it in one call (spends monthly credits, see the table below):

```python
image = pv.images.generate("flat vector hero, a robot at a laptop, no text")
print(image["url"]) # https://img.pixelvault.dev/...
print(image["generation"]) # {'model': 'flux-1-schnell', 'credits_charged': 1, 'credits_remaining': 9}
```

Asyncio:

```python
Expand All @@ -40,11 +48,12 @@ async with AsyncPixelVault() as pv:
| Call | What it does |
|---|---|
| `images.upload(file \| url=, folder=, visibility=, expires_in=, sign_expires_in=)` | Upload a file or import a URL |
| `images.generate(prompt, model=, folder=, visibility=, expires_in=)` | Generate an image from a prompt and host it. Credits: `flux-1-schnell` and the SDXL models 1, Leonardo 20. Free plan: 10 credits a month, `flux-1-schnell` only |
| `images.upload_batch(collection, images)` | Up to 50 images in one collection |
| `images.list(page=, per_page=)` / `images.list_all()` | One page / iterator over all images |
| `images.get(id)` / `images.delete(id)` | Fetch / delete one image |
| `images.sign_url(id, expires_in=)` | Signed URL for a private image |
| `usage.get()` | Plan, usage, limits and what is left this month |
| `usage.get()` | Plan, usage, limits and what is left this month (including generation credits) |
| `keys.list()` / `keys.create(label=, environment=)` / `keys.revoke(id)` | Manage your API keys (secret keys only) |
| `register(email)` | Create an account, returns a first key once |
| `device_login_start(email)` + `device_login_complete(email, code)` | Passwordless key minting |
Expand All @@ -65,7 +74,7 @@ except PixelVaultError as err:
```

The client retries `429` (honoring `Retry-After` up to 30 seconds) and, for `GET`/`DELETE` only, `5xx` and
network errors. Uploads are never retried after a `5xx`, so a lost response cannot create a duplicate image.
network errors. Uploads and `images.generate` are never retried after a `5xx` or a network error, so a lost response cannot create a duplicate image or spend credits twice.
Tune with `max_retries` (default 2) and `timeout` (seconds, default 60).

## License
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "pixelvault"
version = "0.1.0"
version = "0.2.0"
description = "Official Python SDK for PixelVault, agent-first image hosting"
readme = "README.md"
license = "MIT"
Expand Down
7 changes: 5 additions & 2 deletions scripts/check_spec_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,17 @@
import urllib.request

COVERED = {
"upload_image", "batch_upload", "list_images", "get_image", "delete_image",
"upload_image", "generate_image", "batch_upload", "list_images", "get_image", "delete_image",
"sign_image_url", "get_usage", "list_keys", "create_key", "revoke_key",
"register_account", "device_login_start", "device_login_complete",
}
# Not wrapped on purpose: dashboard-session endpoints, the public playground,
# health, and surfaces the SDK does not cover yet.
IGNORED = {
"claim_upload", "dashboard_upload", "playground_upload", "health_check", "serve_image",
"claim_upload", "dashboard_upload", "dashboard_delete_image", "playground_upload",
"health_check", "serve_image",
# Billing: a person pays or cancels, so it is a dashboard/portal flow, not an SDK call.
"start_checkout", "open_billing_portal",
"list_collections", "get_collection", "close_collection",
"start_export", "get_export_job", "download_export",
}
Expand Down
2 changes: 1 addition & 1 deletion src/pixelvault/_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

from .errors import PixelVaultError

__version__ = "0.1.0"
__version__ = "0.2.0"

DEFAULT_BASE_URL = "https://api.pixelvault.dev"
MAX_BACKOFF = 30.0
Expand Down
21 changes: 21 additions & 0 deletions src/pixelvault/_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,27 @@ def sign_url(image_id: str, expires_in: Optional[int]) -> Request:
return Request("POST", f"/v1/images/{seg(image_id)}/sign-url", json=body)


def generate(
prompt: str,
model: Optional[str],
folder: Optional[str],
visibility: Optional[str],
expires_in: Optional[int],
sign_expires_in: Optional[int],
) -> Request:
body = drop_none(
{
"prompt": prompt,
"model": model,
"folder": folder,
"visibility": visibility,
"expires_in": expires_in,
"sign_expires_in": sign_expires_in,
}
)
return Request("POST", "/v1/images/generate", json=body)


def get_usage() -> Request:
return Request("GET", "/v1/usage")

Expand Down
36 changes: 36 additions & 0 deletions src/pixelvault/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
ApiKey,
CreatedKey,
DeviceLoginResult,
GeneratedImage,
Image,
ImagePage,
JsonDict,
Expand Down Expand Up @@ -165,6 +166,28 @@ def upload(
)
return r.data(self._c._send(req)) # type: ignore[no-any-return]

def generate(
self,
prompt: str,
*,
model: Optional[str] = None,
folder: Optional[str] = None,
visibility: Optional[str] = None,
expires_in: Optional[int] = None,
sign_expires_in: Optional[int] = None,
) -> GeneratedImage:
"""Generate an image from ``prompt`` and host it in one call.

Spends credits from the account's monthly grant (``model`` defaults to
``flux-1-schnell``, 1 credit; the Leonardo models cost 20 and need a paid plan).
The result is a normal image plus ``["generation"]`` with the credits charged and left.
Needs a secret key and a verified email. A failed generation is refunded, and a
lost response is never retried, so it cannot be charged twice. A call can take
several seconds (up to about a minute), so keep ``timeout`` generous.
"""
req = r.generate(prompt, model, folder, visibility, expires_in, sign_expires_in)
return r.data(self._c._send(req)) # type: ignore[no-any-return]

def upload_batch(
self, collection: Dict[str, Any], images: List[Dict[str, Any]]
) -> JsonDict:
Expand Down Expand Up @@ -317,6 +340,19 @@ async def upload(
)
return r.data(await self._c._send(req)) # type: ignore[no-any-return]

async def generate(
self,
prompt: str,
*,
model: Optional[str] = None,
folder: Optional[str] = None,
visibility: Optional[str] = None,
expires_in: Optional[int] = None,
sign_expires_in: Optional[int] = None,
) -> GeneratedImage:
req = r.generate(prompt, model, folder, visibility, expires_in, sign_expires_in)
return r.data(await self._c._send(req)) # type: ignore[no-any-return]

async def upload_batch(
self, collection: Dict[str, Any], images: List[Dict[str, Any]]
) -> JsonDict:
Expand Down
23 changes: 23 additions & 0 deletions src/pixelvault/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,26 @@ class Image(TypedDict):
filename: str
folder: Optional[str]
created_at: str
#: Keyed public raster images: the same image as WebP/AVIF, usually far smaller.
optimized_url: NotRequired[str]
#: Present only on auto-expiring uploads.
expires_in: NotRequired[int]
temporary: NotRequired[bool]


class Generation(TypedDict):
"""What an ``images.generate`` call cost."""

model: str
credits_charged: int
#: Credits left this month (they reset on the 1st, UTC).
credits_remaining: int


class GeneratedImage(Image):
generation: Generation


class ListMeta(TypedDict):
total: int
page: int
Expand Down Expand Up @@ -52,13 +67,21 @@ class UsageLimits(TypedDict):
bandwidth_bytes_per_month: int


class GenerationUsage(TypedDict):
credits_used: int
credits_limit: int
credits_remaining: int


class Usage(TypedDict):
plan: str
period: str
resets_at: str
usage: UsageCounts
limits: UsageLimits
remaining: UsageCounts
#: AI image-generation credits for the month (images.generate).
generation: NotRequired[GenerationUsage]


class ApiKey(TypedDict):
Expand Down
94 changes: 94 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,3 +207,97 @@ async def test_async_error():
with pytest.raises(PixelVaultError) as e:
await pv.usage.get()
assert e.value.code == "missing_api_key"


GENERATED = {
**IMG,
"optimized_url": "https://img.pixelvault.dev/p/i.png?fmt=auto&q=85",
"generation": {"model": "flux-1-schnell", "credits_charged": 1, "credits_remaining": 9},
}


def test_generate_posts_json_and_returns_generation():
seen = {}

def handler(req: httpx.Request):
seen["req"] = req
return httpx.Response(201, json={"data": GENERATED})

img = make(handler).images.generate(
"a red bicycle", model="lucid-origin", folder="heroes", expires_in=3600
)
req = seen["req"]
assert req.method == "POST"
assert str(req.url) == "https://api.pixelvault.dev/v1/images/generate"
assert req.headers["authorization"] == "Bearer pv_live_test"
assert req.headers["content-type"].startswith("application/json")
assert json.loads(req.content) == {
"prompt": "a red bicycle",
"model": "lucid-origin",
"folder": "heroes",
"expires_in": 3600,
}
assert img["generation"]["credits_remaining"] == 9
assert img["optimized_url"].endswith("fmt=auto&q=85")


def test_generate_omits_unset_options():
seen = {}

def handler(req: httpx.Request):
seen["body"] = json.loads(req.content)
return httpx.Response(201, json={"data": GENERATED})

make(handler).images.generate("a lake")
assert seen["body"] == {"prompt": "a lake"}


def test_generate_credit_errors_carry_the_api_code():
def handler(req: httpx.Request):
return httpx.Response(
402,
json={"error": {"code": "generation_credits_exhausted", "message": "0 left"}},
)

with pytest.raises(PixelVaultError) as exc:
make(handler).images.generate("x")
assert exc.value.status == 402
assert exc.value.code == "generation_credits_exhausted"


def test_generate_is_not_retried_after_a_5xx():
calls = []

def handler(req: httpx.Request):
calls.append(1)
return httpx.Response(
502, json={"error": {"code": "generation_failed", "message": "try again"}}
)

with pytest.raises(PixelVaultError) as exc:
make(handler).images.generate("x")
assert exc.value.code == "generation_failed"
assert len(calls) == 1 # a lost response must never spend credits twice


def test_usage_exposes_generation_credits():
usage = {
"plan": "free",
"generation": {"credits_used": 1, "credits_limit": 10, "credits_remaining": 9},
}
got = make(lambda r: httpx.Response(200, json={"data": usage})).usage.get()
assert got["generation"]["credits_remaining"] == 9


@pytest.mark.asyncio
async def test_async_generate():
seen = {}

def handler(req: httpx.Request):
seen["body"] = json.loads(req.content)
return httpx.Response(201, json={"data": GENERATED})

async with amake(handler) as pv:
img = await pv.images.generate("a mountain", visibility="private")
assert seen["body"] == {"prompt": "a mountain", "visibility": "private"}
assert img["generation"]["model"] == "flux-1-schnell"
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading