diff --git a/README.md b/README.md index 3ef0997..58a1195 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 | @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 8054984..465f344 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/scripts/check_spec_coverage.py b/scripts/check_spec_coverage.py index da19da4..4a20c8f 100644 --- a/scripts/check_spec_coverage.py +++ b/scripts/check_spec_coverage.py @@ -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", } diff --git a/src/pixelvault/_core.py b/src/pixelvault/_core.py index cd291b3..3d16c65 100644 --- a/src/pixelvault/_core.py +++ b/src/pixelvault/_core.py @@ -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 diff --git a/src/pixelvault/_resources.py b/src/pixelvault/_resources.py index 051bda8..a3e34bb 100644 --- a/src/pixelvault/_resources.py +++ b/src/pixelvault/_resources.py @@ -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") diff --git a/src/pixelvault/client.py b/src/pixelvault/client.py index 7e6336a..d8a01c3 100644 --- a/src/pixelvault/client.py +++ b/src/pixelvault/client.py @@ -23,6 +23,7 @@ ApiKey, CreatedKey, DeviceLoginResult, + GeneratedImage, Image, ImagePage, JsonDict, @@ -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: @@ -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: diff --git a/src/pixelvault/types.py b/src/pixelvault/types.py index 8ecafe3..27da47d 100644 --- a/src/pixelvault/types.py +++ b/src/pixelvault/types.py @@ -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 @@ -52,6 +67,12 @@ 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 @@ -59,6 +80,8 @@ class Usage(TypedDict): usage: UsageCounts limits: UsageLimits remaining: UsageCounts + #: AI image-generation credits for the month (images.generate). + generation: NotRequired[GenerationUsage] class ApiKey(TypedDict): diff --git a/tests/test_client.py b/tests/test_client.py index 8429314..b951b23 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -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" diff --git a/uv.lock b/uv.lock index 4123d7f..9319f27 100644 --- a/uv.lock +++ b/uv.lock @@ -516,7 +516,7 @@ wheels = [ [[package]] name = "pixelvault" -version = "0.1.0" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "httpx" },