wavespeed/fibo-edit
0
1"""Minimal WaveSpeed v3 API client.2 3Shared verbatim by every Space in the wavespeed org. Generated from4_shared/spaceapp/ — edit there and re-run _shared/build_apps.py, never edit the5copy inside a Space.6 7The user supplies their own API key through the UI. It is used to sign requests8to api.wavespeed.ai and nothing else: it is never logged, never written to9disk, never placed in a Gradio component value, and is stripped out of every10error message before that message can reach a browser (see `redact`). An11exception raised by `requests` can carry the full request headers in its text,12which is exactly how a key ends up in a user-visible traceback, so every raise13in this module goes through `redact` first.14 15API reference: https://wavespeed.ai/docs/rest-api16"""17 18from __future__ import annotations19 20import time21from typing import Any22 23import requests24 25API_BASE = "https://api.wavespeed.ai/api/v3"26UPLOAD_URL = f"{API_BASE}/media/upload/binary"27 28# The docs ask for >= 2s between polls of the same task, easing toward 5-10s29# for long jobs. Anything faster risks being throttled.30POLL_START = 2.031POLL_MAX = 8.032POLL_GROWTH = 1.2533POLL_TIMEOUT = 60034 35TERMINAL_OK = "completed"36TERMINAL_BAD = ("failed", "cancelled", "timeout")37 38 39class WaveSpeedError(Exception):40 """User-facing error. The message is always key-free."""41 42 43def redact(text: Any, key: str | None) -> str:44 """Remove the API key (and any bearer token) from text headed for a user."""45 s = str(text)46 if key:47 k = key.strip()48 if k:49 s = s.replace(k, "***")50 # Defend against a partially-quoted key in a repr.51 if len(k) > 12:52 s = s.replace(k[:12], "***")53 # Catch any Authorization header echoed by a library.54 import re55 56 s = re.sub(r"(?i)(bearer\s+)[A-Za-z0-9._\-]+", r"\1***", s)57 s = re.sub(r"(?i)('authorization':\s*')[^']*", r"\1***", s)58 return s59 60 61def _headers(key: str, json: bool = False) -> dict:62 h = {"Authorization": f"Bearer {key.strip()}"}63 if json:64 h["Content-Type"] = "application/json"65 return h66 67 68def _check(resp: requests.Response, key: str) -> dict:69 if resp.status_code == 401:70 raise WaveSpeedError("Invalid API key. Check the key and try again.")71 if resp.status_code == 402:72 raise WaveSpeedError("This account is out of credit.")73 if resp.status_code == 429:74 raise WaveSpeedError("Rate limit or quota exceeded. Wait and retry.")75 if resp.status_code >= 400:76 raise WaveSpeedError(77 redact(f"API error {resp.status_code}: {resp.text[:300]}", key)78 )79 try:80 body = resp.json()81 except ValueError:82 raise WaveSpeedError("API returned a non-JSON response.") from None83 if body.get("code") != 200:84 raise WaveSpeedError(redact(body.get("message", "Unknown API error"), key))85 return body.get("data", {}) or {}86 87 88def upload(key: str, path: str) -> str:89 """Upload a local file, returning the URL to reference it by."""90 try:91 with open(path, "rb") as fh:92 resp = requests.post(93 UPLOAD_URL, headers=_headers(key), files={"file": fh}, timeout=12094 )95 except requests.RequestException as e:96 raise WaveSpeedError(redact(f"Upload failed: {e}", key)) from None97 data = _check(resp, key)98 url = data.get("download_url") or data.get("url")99 if not url:100 raise WaveSpeedError("Upload succeeded but returned no URL.")101 return url102 103 104def submit(key: str, model: str, payload: dict) -> str:105 """Start a job and return its request id.106 107 Deliberately not retried: the docs warn that repeating a POST can bill the108 caller twice. Only the GET poll below is safe to retry.109 """110 try:111 resp = requests.post(112 f"{API_BASE}/{model}", headers=_headers(key, json=True), json=payload, timeout=60113 )114 except requests.RequestException as e:115 raise WaveSpeedError(redact(f"Could not reach the API: {e}", key)) from None116 data = _check(resp, key)117 rid = data.get("id")118 if not rid:119 raise WaveSpeedError("API accepted the request but returned no task id.")120 return rid121 122 123def poll(key: str, request_id: str, on_tick=None) -> list[str]:124 """Poll a task to completion and return its output URLs."""125 url = f"{API_BASE}/predictions/{request_id}/result"126 deadline = time.time() + POLL_TIMEOUT127 delay = POLL_START128 while time.time() < deadline:129 time.sleep(delay)130 delay = min(delay * POLL_GROWTH, POLL_MAX)131 try:132 resp = requests.get(url, headers=_headers(key), timeout=60)133 except requests.RequestException:134 continue # transient; a GET is safe to repeat135 data = _check(resp, key)136 status = data.get("status", "")137 if status == TERMINAL_OK:138 outputs = data.get("outputs") or []139 if not outputs:140 raise WaveSpeedError("Generation finished but produced no output.")141 return outputs142 if status in TERMINAL_BAD:143 detail = redact(data.get("error") or status, key)144 raise WaveSpeedError(f"Generation {status}: {detail}")145 if on_tick:146 on_tick(status)147 raise WaveSpeedError("Timed out waiting for the result. The job may still finish.")148 149 150def run(key: str, model: str, payload: dict, on_tick=None) -> list[str]:151 return poll(key, submit(key, model, payload), on_tick=on_tick)152 