|
|
|
@@ -0,0 +1,290 @@
|
|
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""Submit a Chorus text-to-image task, poll it, and download its first image."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import argparse
|
|
|
|
|
import ipaddress
|
|
|
|
|
import json
|
|
|
|
|
import os
|
|
|
|
|
import sys
|
|
|
|
|
import time
|
|
|
|
|
import uuid
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from urllib.error import HTTPError, URLError
|
|
|
|
|
from urllib.parse import urljoin, urlsplit
|
|
|
|
|
from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ChorusError(RuntimeError):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def origin(url: str) -> tuple[str, str, int | None]:
|
|
|
|
|
parsed = urlsplit(url)
|
|
|
|
|
port = parsed.port
|
|
|
|
|
if port is None:
|
|
|
|
|
port = 443 if parsed.scheme.lower() == "https" else 80 if parsed.scheme.lower() == "http" else None
|
|
|
|
|
return parsed.scheme.lower(), (parsed.hostname or "").lower(), port
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SameOriginRedirectHandler(HTTPRedirectHandler):
|
|
|
|
|
def redirect_request(self, request, file_pointer, code, message, headers, new_url):
|
|
|
|
|
if origin(request.full_url) != origin(new_url):
|
|
|
|
|
raise ChorusError("Chorus attempted a cross-origin redirect")
|
|
|
|
|
return super().redirect_request(request, file_pointer, code, message, headers, new_url)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def open_url(request: Request, timeout: float):
|
|
|
|
|
host = urlsplit(request.full_url).hostname or ""
|
|
|
|
|
try:
|
|
|
|
|
loopback = ipaddress.ip_address(host).is_loopback
|
|
|
|
|
except ValueError:
|
|
|
|
|
loopback = host.lower() == "localhost"
|
|
|
|
|
if loopback:
|
|
|
|
|
return build_opener(ProxyHandler({}), SameOriginRedirectHandler()).open(request, timeout=timeout)
|
|
|
|
|
return build_opener(SameOriginRedirectHandler()).open(request, timeout=timeout)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def read_env(path: Path) -> dict[str, str]:
|
|
|
|
|
if not path.is_file():
|
|
|
|
|
raise ChorusError(f"environment file not found: {path}")
|
|
|
|
|
values: dict[str, str] = {}
|
|
|
|
|
for number, raw_line in enumerate(path.read_text(encoding="utf-8-sig").splitlines(), 1):
|
|
|
|
|
line = raw_line.strip()
|
|
|
|
|
if not line or line.startswith("#"):
|
|
|
|
|
continue
|
|
|
|
|
if line.lower().startswith("export "):
|
|
|
|
|
line = line[7:].lstrip()
|
|
|
|
|
if "=" not in line:
|
|
|
|
|
raise ChorusError(f"invalid environment entry at line {number}")
|
|
|
|
|
key, value = line.split("=", 1)
|
|
|
|
|
key = key.strip().upper()
|
|
|
|
|
value = value.strip()
|
|
|
|
|
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
|
|
|
|
value = value[1:-1]
|
|
|
|
|
values[key] = value
|
|
|
|
|
return values
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def required_setting(values: dict[str, str], names: tuple[str, ...], label: str) -> str:
|
|
|
|
|
for name in names:
|
|
|
|
|
value = values.get(name, "").strip()
|
|
|
|
|
if value:
|
|
|
|
|
return value
|
|
|
|
|
raise ChorusError(f"{label} is missing from chorus.env")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def output_path(args: argparse.Namespace) -> Path:
|
|
|
|
|
if args.output:
|
|
|
|
|
if args.output_dir or args.filename:
|
|
|
|
|
raise ChorusError("use --output or --output-dir with --filename, not both")
|
|
|
|
|
return Path(args.output).expanduser().resolve()
|
|
|
|
|
if not args.output_dir or not args.filename:
|
|
|
|
|
raise ChorusError("provide --output or both --output-dir and --filename")
|
|
|
|
|
if Path(args.filename).name != args.filename:
|
|
|
|
|
raise ChorusError("--filename must not contain directory components")
|
|
|
|
|
return (Path(args.output_dir).expanduser() / args.filename).resolve()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def multipart_body(prompt: str) -> tuple[bytes, str]:
|
|
|
|
|
boundary = f"chorus-{uuid.uuid4().hex}"
|
|
|
|
|
metadata = json.dumps(
|
|
|
|
|
{"capability": "image_generate", "role_rule": "", "images": []},
|
|
|
|
|
ensure_ascii=False,
|
|
|
|
|
separators=(",", ":"),
|
|
|
|
|
)
|
|
|
|
|
parts: list[bytes] = []
|
|
|
|
|
for name, value, content_type in (
|
|
|
|
|
("prompt", prompt, "text/plain; charset=utf-8"),
|
|
|
|
|
("metadata", metadata, "application/json; charset=utf-8"),
|
|
|
|
|
):
|
|
|
|
|
parts.extend(
|
|
|
|
|
[
|
|
|
|
|
f"--{boundary}\r\n".encode(),
|
|
|
|
|
f'Content-Disposition: form-data; name="{name}"\r\n'.encode(),
|
|
|
|
|
f"Content-Type: {content_type}\r\n\r\n".encode(),
|
|
|
|
|
value.encode("utf-8"),
|
|
|
|
|
b"\r\n",
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
parts.append(f"--{boundary}--\r\n".encode())
|
|
|
|
|
return b"".join(parts), boundary
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def error_message(payload: bytes, fallback: str) -> str:
|
|
|
|
|
try:
|
|
|
|
|
parsed = json.loads(payload.decode("utf-8"))
|
|
|
|
|
error = parsed.get("error", {}) if isinstance(parsed, dict) else {}
|
|
|
|
|
code = error.get("code")
|
|
|
|
|
message = error.get("message")
|
|
|
|
|
if code and message:
|
|
|
|
|
return f"{code}: {message}"
|
|
|
|
|
if message:
|
|
|
|
|
return str(message)
|
|
|
|
|
except (UnicodeDecodeError, json.JSONDecodeError, AttributeError):
|
|
|
|
|
pass
|
|
|
|
|
return fallback
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def request_json(
|
|
|
|
|
url: str,
|
|
|
|
|
api_key: str,
|
|
|
|
|
method: str = "GET",
|
|
|
|
|
body: bytes | None = None,
|
|
|
|
|
headers: dict[str, str] | None = None,
|
|
|
|
|
timeout: float = 30,
|
|
|
|
|
) -> dict:
|
|
|
|
|
request_headers = {"Accept": "application/json", "Authorization": f"Bearer {api_key}"}
|
|
|
|
|
if headers:
|
|
|
|
|
request_headers.update(headers)
|
|
|
|
|
request = Request(url, data=body, headers=request_headers, method=method)
|
|
|
|
|
try:
|
|
|
|
|
with open_url(request, timeout=timeout) as response:
|
|
|
|
|
payload = response.read()
|
|
|
|
|
except HTTPError as exc:
|
|
|
|
|
payload = exc.read()
|
|
|
|
|
raise ChorusError(f"Chorus HTTP {exc.code}: {error_message(payload, exc.reason)}") from None
|
|
|
|
|
except URLError as exc:
|
|
|
|
|
raise ChorusError(f"cannot reach Chorus: {exc.reason}") from None
|
|
|
|
|
try:
|
|
|
|
|
parsed = json.loads(payload.decode("utf-8"))
|
|
|
|
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
|
|
|
raise ChorusError("Chorus returned invalid JSON") from exc
|
|
|
|
|
if not isinstance(parsed, dict):
|
|
|
|
|
raise ChorusError("Chorus returned an unexpected response")
|
|
|
|
|
return parsed
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def submit(base_url: str, api_key: str, prompt: str, idempotency_key: str) -> dict:
|
|
|
|
|
body, boundary = multipart_body(prompt)
|
|
|
|
|
return request_json(
|
|
|
|
|
f"{base_url}/openapi/v1/generations/image",
|
|
|
|
|
api_key,
|
|
|
|
|
method="POST",
|
|
|
|
|
body=body,
|
|
|
|
|
headers={
|
|
|
|
|
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
|
|
|
|
"Idempotency-Key": idempotency_key,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def wait_for_result(
|
|
|
|
|
base_url: str,
|
|
|
|
|
api_key: str,
|
|
|
|
|
generation_id: int,
|
|
|
|
|
timeout: float,
|
|
|
|
|
poll_interval: float,
|
|
|
|
|
) -> dict:
|
|
|
|
|
deadline = time.monotonic() + timeout
|
|
|
|
|
while True:
|
|
|
|
|
generation = request_json(
|
|
|
|
|
f"{base_url}/openapi/v1/generations/{generation_id}", api_key
|
|
|
|
|
)
|
|
|
|
|
status = generation.get("status")
|
|
|
|
|
if status == "succeeded":
|
|
|
|
|
return generation
|
|
|
|
|
if status == "failed":
|
|
|
|
|
code = generation.get("error_code") or "generation_failed"
|
|
|
|
|
message = generation.get("error_message") or "generation failed"
|
|
|
|
|
raise ChorusError(f"{code}: {message}")
|
|
|
|
|
if status not in {"pending", "running"}:
|
|
|
|
|
raise ChorusError(f"unexpected generation status: {status!r}")
|
|
|
|
|
if time.monotonic() >= deadline:
|
|
|
|
|
raise ChorusError(f"generation {generation_id} did not finish within {timeout:g} seconds")
|
|
|
|
|
time.sleep(min(poll_interval, max(0.0, deadline - time.monotonic())))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def choose_image(generation: dict) -> str:
|
|
|
|
|
outputs = generation.get("outputs")
|
|
|
|
|
if not isinstance(outputs, list):
|
|
|
|
|
raise ChorusError("completed generation has no outputs")
|
|
|
|
|
for output in outputs:
|
|
|
|
|
if isinstance(output, dict) and output.get("kind") == "image" and output.get("url"):
|
|
|
|
|
return str(output["url"])
|
|
|
|
|
raise ChorusError("completed generation has no image output")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def download(url: str, api_key: str, destination: Path, overwrite: bool) -> None:
|
|
|
|
|
if destination.exists() and not overwrite:
|
|
|
|
|
raise ChorusError(f"output already exists: {destination}")
|
|
|
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
temporary = destination.with_name(f".{destination.name}.{uuid.uuid4().hex}.part")
|
|
|
|
|
request = Request(url, headers={"Accept": "image/*", "Authorization": f"Bearer {api_key}"})
|
|
|
|
|
try:
|
|
|
|
|
with open_url(request, timeout=60) as response, temporary.open("wb") as output:
|
|
|
|
|
content_type = response.headers.get_content_type()
|
|
|
|
|
if not content_type.startswith("image/"):
|
|
|
|
|
raise ChorusError(f"output is not an image: {content_type}")
|
|
|
|
|
while chunk := response.read(1024 * 1024):
|
|
|
|
|
output.write(chunk)
|
|
|
|
|
os.replace(temporary, destination)
|
|
|
|
|
except HTTPError as exc:
|
|
|
|
|
raise ChorusError(f"image download HTTP {exc.code}: {error_message(exc.read(), exc.reason)}") from None
|
|
|
|
|
except URLError as exc:
|
|
|
|
|
raise ChorusError(f"cannot download image: {exc.reason}") from None
|
|
|
|
|
finally:
|
|
|
|
|
if temporary.exists():
|
|
|
|
|
temporary.unlink()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
|
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
|
|
|
parser.add_argument("--env", default="chorus.env", help="path to chorus.env")
|
|
|
|
|
parser.add_argument("--prompt", required=True)
|
|
|
|
|
parser.add_argument("--output")
|
|
|
|
|
parser.add_argument("--output-dir")
|
|
|
|
|
parser.add_argument("--filename")
|
|
|
|
|
parser.add_argument("--timeout", type=float)
|
|
|
|
|
parser.add_argument("--poll-interval", type=float)
|
|
|
|
|
parser.add_argument("--idempotency-key")
|
|
|
|
|
parser.add_argument("--overwrite", action="store_true")
|
|
|
|
|
return parser.parse_args()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main() -> int:
|
|
|
|
|
args = parse_args()
|
|
|
|
|
try:
|
|
|
|
|
values = read_env(Path(args.env).expanduser().resolve())
|
|
|
|
|
base_url = required_setting(
|
|
|
|
|
values, ("CHORUS_URL", "CHORUS_API_URL", "URL"), "Chorus URL"
|
|
|
|
|
).rstrip("/")
|
|
|
|
|
api_key = required_setting(
|
|
|
|
|
values,
|
|
|
|
|
("CHORUS_API_KEY", "CHORUS_APIKEY", "APIKEY", "API_KEY"),
|
|
|
|
|
"Chorus API key",
|
|
|
|
|
)
|
|
|
|
|
destination = output_path(args)
|
|
|
|
|
if destination.exists() and not args.overwrite:
|
|
|
|
|
raise ChorusError(f"output already exists: {destination}")
|
|
|
|
|
timeout = args.timeout if args.timeout is not None else float(
|
|
|
|
|
values.get("CHORUS_TIMEOUT_SECONDS", "600")
|
|
|
|
|
)
|
|
|
|
|
poll_interval = args.poll_interval if args.poll_interval is not None else float(
|
|
|
|
|
values.get("CHORUS_POLL_INTERVAL_SECONDS", "2")
|
|
|
|
|
)
|
|
|
|
|
if not args.prompt.strip():
|
|
|
|
|
raise ChorusError("prompt must not be empty")
|
|
|
|
|
if timeout <= 0 or poll_interval <= 0:
|
|
|
|
|
raise ChorusError("timeout and poll interval must be positive")
|
|
|
|
|
idempotency_key = args.idempotency_key or f"chorus-image-{uuid.uuid4().hex}"
|
|
|
|
|
generation = submit(base_url, api_key, args.prompt, idempotency_key)
|
|
|
|
|
generation_id = generation.get("id")
|
|
|
|
|
if not isinstance(generation_id, int) or generation_id <= 0:
|
|
|
|
|
raise ChorusError("submission response has no valid generation ID")
|
|
|
|
|
print(f"Chorus generation {generation_id} submitted", flush=True)
|
|
|
|
|
generation = wait_for_result(base_url, api_key, generation_id, timeout, poll_interval)
|
|
|
|
|
image_url = urljoin(f"{base_url}/", choose_image(generation))
|
|
|
|
|
if origin(image_url) != origin(base_url):
|
|
|
|
|
raise ChorusError("Chorus returned a cross-origin image URL")
|
|
|
|
|
download(image_url, api_key, destination, args.overwrite)
|
|
|
|
|
print(f"Saved Chorus generation {generation_id} to {destination}")
|
|
|
|
|
return 0
|
|
|
|
|
except (ChorusError, OSError, ValueError) as exc:
|
|
|
|
|
print(f"chorus: {exc}", file=sys.stderr)
|
|
|
|
|
return 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
raise SystemExit(main())
|