feat: add asynchronous Shujk image generation skill

This commit is contained in:
2026-07-17 00:16:00 +08:00
commit 5ae8622f2d
3 changed files with 163 additions and 0 deletions
+70
View File
@@ -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.
+4
View File
@@ -0,0 +1,4 @@
interface:
display_name: "Shujk Async ImageGen API"
short_description: "通过 server4 异步入口提交、轮询并下载图片"
default_prompt: "使用 $shujkimagegen-async-api 提交异步图片任务并下载结果。"
+89
View File
@@ -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())