feat: add asynchronous Shujk image generation skill
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
---
|
||||
name: shujkimagegen-async-api
|
||||
description: Generate images through the server4 asynchronous Sub2API endpoint, poll the task, and download the resulting image.
|
||||
---
|
||||
|
||||
# Shujk Async ImageGen API
|
||||
|
||||
Use this helper when image generation should run asynchronously on server4.
|
||||
|
||||
## Boundary
|
||||
|
||||
- Do not print, copy, commit, or summarize API keys.
|
||||
- Read only `OPENAI_API_KEY` from `~/.codex/auth.json`; do not read plaintext `.env` files or unrelated secrets.
|
||||
- Keep the durable source in the Gitea repo `shujakuin/skill-shujkimagegen-api`; treat `ai-infra/skills/sources/shujkimagegen-api/` and HOME runtime copies as managed mirrors.
|
||||
- Do not place this skill under the infra repo's `.agents/skills/` or manage it through cc-switch WebDAV.
|
||||
- Treat this as a thin wrapper around the system `imagegen` CLI; do not copy or patch the system script.
|
||||
- Current HOME runtime placements are `pc` and `a100` under `~/.agents/skills/shujkimagegen-api/`. Update only explicitly modeled placements.
|
||||
|
||||
## Endpoint
|
||||
|
||||
- Async endpoint: `https://sub2apius.shujk.top/v1/images/generations/async`
|
||||
|
||||
The `/v1` suffix is required because `/models` is an application page while `/v1/models` is the authenticated OpenAI-compatible API.
|
||||
|
||||
## Workflow
|
||||
|
||||
Run the wrapper:
|
||||
|
||||
```bash
|
||||
python3 ~/.agents/skills/shujkimagegen-async-api/scripts/shujk_imagegen.py \
|
||||
--prompt "..." --out output/imagegen/example.png
|
||||
```
|
||||
|
||||
The command submits a task, honors `Retry-After`, polls until completion, and downloads the object-storage URL.
|
||||
|
||||
5. Save project-bound assets inside the active workspace and report the output path and final prompt.
|
||||
|
||||
For source validation before placement, use the equivalent wrapper under the standalone checkout or infra mirror.
|
||||
|
||||
## Installation Validation
|
||||
|
||||
After placing the skill in `~/.agents/skills/shujkimagegen-api/`, validate the installed copy before reporting success:
|
||||
|
||||
1. Confirm `SKILL.md`, `agents/openai.yaml`, and `scripts/shujk_imagegen.py` exist.
|
||||
2. Check whether the selected Python environment can import `openai`. Do not add it to an unrelated project's dependencies just for validation. If it is absent and `uv` is available, use an ephemeral environment:
|
||||
|
||||
```bash
|
||||
uv run --with openai python ~/.agents/skills/shujkimagegen-api/scripts/shujk_imagegen.py \
|
||||
--print-config-redacted
|
||||
```
|
||||
|
||||
3. Run a no-cost command-path test:
|
||||
|
||||
```bash
|
||||
uv run --with openai python ~/.agents/skills/shujkimagegen-api/scripts/shujk_imagegen.py \
|
||||
generate --prompt "installation validation" --out /tmp/shujkimagegen-dry-run.png \
|
||||
--dry-run --no-augment
|
||||
```
|
||||
|
||||
4. When the user authorizes a real API test, generate one low-cost image with `gpt-image-2`, `quality=low`, and a simple prompt. Confirm the command exits successfully and inspect the resulting file as a real, non-empty image.
|
||||
5. Remove test artifacts that are not intended deliverables.
|
||||
|
||||
If validation requires a compatibility fix, edit the standalone Gitea checkout first, rerun validation, commit and push it, then resync the infra mirror and selected runtime placement. Never leave the only fix in `~/.agents/skills/`.
|
||||
|
||||
## Failure Handling
|
||||
|
||||
- If `OPENAI_API_KEY` is absent, report that auth is unavailable without revealing file contents.
|
||||
- If the system imagegen CLI is missing, report its expected path.
|
||||
- If the endpoint rejects a model or option, retry only with an explicitly compatible model or option.
|
||||
- Do not silently fall back to `sub2apius.shujk.top` or another Cloudflare-proxied endpoint.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Shujk Async ImageGen API"
|
||||
short_description: "通过 server4 异步入口提交、轮询并下载图片"
|
||||
default_prompt: "使用 $shujkimagegen-async-api 提交异步图片任务并下载结果。"
|
||||
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Submit and poll asynchronous Sub2API image tasks."""
|
||||
from __future__ import annotations
|
||||
import argparse, base64, json, os, sys, time
|
||||
from pathlib import Path
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import HTTPError, URLError
|
||||
|
||||
ORIGIN = "https://sub2apius.shujk.top"
|
||||
AUTH = Path.home() / ".codex/auth.json"
|
||||
|
||||
def die(msg: str) -> None:
|
||||
print(f"Error: {msg}", file=sys.stderr); raise SystemExit(1)
|
||||
|
||||
def key(path: Path) -> str:
|
||||
try: data = json.loads(path.read_text())
|
||||
except Exception as exc: die(f"cannot read Codex auth: {exc}")
|
||||
value = data.get("OPENAI_API_KEY") if isinstance(data, dict) else None
|
||||
if not isinstance(value, str) or not value.strip(): die("OPENAI_API_KEY is missing")
|
||||
return value.strip()
|
||||
|
||||
def request(method: str, url: str, token: str, body: bytes | None = None) -> tuple[int, dict, dict]:
|
||||
headers = {"Authorization": f"Bearer {token}", "Accept": "application/json", "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/131 Safari/537.36"}
|
||||
if body is not None: headers["Content-Type"] = "application/json"
|
||||
req = Request(url, data=body, headers=headers, method=method)
|
||||
try:
|
||||
with urlopen(req, timeout=60) as resp:
|
||||
return resp.status, dict(resp.headers), json.loads(resp.read())
|
||||
except HTTPError as exc:
|
||||
raw = exc.read().decode(errors="replace")
|
||||
try: payload = json.loads(raw)
|
||||
except json.JSONDecodeError: payload = {"error": raw}
|
||||
return exc.code, dict(exc.headers), payload
|
||||
except URLError as exc: die(f"request failed: {exc}")
|
||||
|
||||
def main() -> int:
|
||||
p = argparse.ArgumentParser(description="Asynchronous Shujk image generation")
|
||||
p.add_argument("--auth-file", type=Path, default=AUTH)
|
||||
p.add_argument("--model", default="gpt-image-2")
|
||||
p.add_argument("--prompt", required=True)
|
||||
p.add_argument("--out", type=Path, required=True)
|
||||
p.add_argument("--size", default="1024x1024")
|
||||
p.add_argument("--quality", default="low")
|
||||
p.add_argument("--poll-seconds", type=float, default=3)
|
||||
p.add_argument("--timeout-seconds", type=float, default=1800)
|
||||
p.add_argument("--dry-run", action="store_true")
|
||||
args = p.parse_args()
|
||||
payload = {"model": args.model, "prompt": args.prompt, "size": args.size, "quality": args.quality}
|
||||
if args.dry_run:
|
||||
print(json.dumps({"url": ORIGIN + "/v1/images/generations/async", "payload": payload}, indent=2)); return 0
|
||||
token = key(args.auth_file)
|
||||
code, headers, task = request("POST", ORIGIN + "/v1/images/generations/async", token, json.dumps(payload).encode())
|
||||
if code != 202: die(f"async submit failed ({code}): {task}")
|
||||
task_url = task.get("poll_url") or f"{ORIGIN}/v1/images/tasks/{task.get('task_id')}"
|
||||
if task_url.startswith("/"): task_url = ORIGIN + task_url
|
||||
deadline = time.monotonic() + args.timeout_seconds
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
code, headers, state = request("GET", task_url, token)
|
||||
except TimeoutError:
|
||||
time.sleep(args.poll_seconds)
|
||||
continue
|
||||
if code >= 400: die(f"async poll failed ({code}): {state}")
|
||||
status = state.get("status")
|
||||
if status == "completed":
|
||||
url = state.get("image_url")
|
||||
if not url: die("completed task has no image_url")
|
||||
data = None
|
||||
last_error = None
|
||||
for attempt in range(6):
|
||||
try:
|
||||
with urlopen(Request(url, headers={"Accept": "image/*", "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/131 Safari/537.36"}), timeout=120) as resp:
|
||||
data = resp.read()
|
||||
break
|
||||
except HTTPError as exc:
|
||||
last_error = exc
|
||||
if exc.code != 403 or attempt == 5: break
|
||||
time.sleep(2 + attempt)
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
break
|
||||
if data is None: die(f"image download failed: {last_error}")
|
||||
args.out.parent.mkdir(parents=True, exist_ok=True); args.out.write_bytes(data)
|
||||
print(json.dumps({"task_id": task.get("task_id"), "status": status, "out": str(args.out), "bytes": len(data)})); return 0
|
||||
if status == "failed": die(f"image task failed: {state.get('error')}")
|
||||
delay = float(headers.get("Retry-After", args.poll_seconds)); time.sleep(max(1.0, delay))
|
||||
die("timed out waiting for image task")
|
||||
|
||||
if __name__ == "__main__": raise SystemExit(main())
|
||||
Reference in New Issue
Block a user