Files
2026-07-13 12:34:41 +08:00

733 lines
27 KiB
Python

#!/usr/bin/env python3
"""Small CLI for Agnes AI text, image, and video generation APIs."""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import time
import urllib.parse
import urllib.error
import urllib.request
from typing import Any
BASE_URL = "https://apihub.agnes-ai.com"
TEXT_MODEL = "agnes-2.0-flash"
IMAGE_MODEL = "agnes-image-2.1-flash"
VIDEO_MODEL = "agnes-video-v2.0"
SIZE_RE = re.compile(r"^[1-9]\d*x[1-9]\d*$")
def get_api_key() -> str:
for name in ("AGNES_API_KEY", "AGNES_API_TOKEN", "APIHUB_AGNES_API_KEY"):
value = os.environ.get(name)
if value:
return value
raise SystemExit(
"Missing API key. Set AGNES_API_KEY, AGNES_API_TOKEN, or APIHUB_AGNES_API_KEY."
)
def request_json(method: str, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
body = None if payload is None else json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
BASE_URL + path,
data=body,
method=method,
headers={
"Authorization": f"Bearer {get_api_key()}",
"Content-Type": "application/json",
},
)
try:
with urllib.request.urlopen(req, timeout=120) as resp:
text = resp.read().decode("utf-8")
return json.loads(text) if text else {}
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise SystemExit(f"HTTP {exc.code} from {path}: {detail}") from exc
except urllib.error.URLError as exc:
raise SystemExit(f"Request failed for {path}: {exc}") from exc
def request_text(method: str, path: str, payload: dict[str, Any] | None = None) -> str:
body = None if payload is None else json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
BASE_URL + path,
data=body,
method=method,
headers={
"Authorization": f"Bearer {get_api_key()}",
"Content-Type": "application/json",
},
)
try:
with urllib.request.urlopen(req, timeout=120) as resp:
return resp.read().decode("utf-8", errors="replace")
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise SystemExit(f"HTTP {exc.code} from {path}: {detail}") from exc
except urllib.error.URLError as exc:
raise SystemExit(f"Request failed for {path}: {exc}") from exc
def stream_summary(payload: dict[str, Any]) -> dict[str, Any]:
raw = request_text("POST", "/v1/chat/completions", payload)
event_count = 0
done = False
content_parts: list[str] = []
for line in raw.splitlines():
line = line.strip()
if not line.startswith("data:"):
continue
data = line.removeprefix("data:").strip()
if data == "[DONE]":
done = True
elif data:
event_count += 1
try:
event = json.loads(data)
except json.JSONDecodeError:
continue
try:
delta = event["choices"][0].get("delta", {})
except (KeyError, IndexError, TypeError, AttributeError):
continue
content = delta.get("content")
if isinstance(content, str):
content_parts.append(content)
return {
"type": "text-stream",
"content": "".join(content_parts) or None,
"events": event_count,
"done": done,
"raw_prefix": raw[:200],
}
def print_json(data: Any) -> None:
print(json.dumps(data, ensure_ascii=False, indent=2))
def parse_json_arg(name: str, value: str) -> Any:
try:
return json.loads(value)
except json.JSONDecodeError as exc:
raise SystemExit(f"Invalid JSON for {name}: {exc.msg} at position {exc.pos}") from exc
def needs_english_translation(prompt: str) -> bool:
return any(ord(ch) > 127 for ch in prompt)
def translate_prompt_to_english(prompt: str) -> str:
payload = {
"model": TEXT_MODEL,
"messages": [
{
"role": "system",
"content": (
"Translate the user's image/video generation prompt into fluent English. "
"Preserve all concrete visual details, style words, camera motion, lighting, "
"composition constraints, and negative instructions. Return only the English prompt."
),
},
{"role": "user", "content": prompt},
],
"temperature": 0,
"max_tokens": 800,
}
data = request_json("POST", "/v1/chat/completions", payload)
try:
translated = data["choices"][0]["message"]["content"].strip()
except (KeyError, IndexError, TypeError) as exc:
raise SystemExit(f"Prompt translation failed: {json.dumps(data, ensure_ascii=False)}") from exc
if not translated:
raise SystemExit("Prompt translation failed: empty translated prompt")
return translated
def prepare_generation_prompt(prompt: str, translate: bool = True) -> tuple[str, str | None]:
if translate and needs_english_translation(prompt):
translated = translate_prompt_to_english(prompt)
return translated, translated
return prompt, None
def extract_text_content(data: dict[str, Any]) -> str | None:
try:
content = data["choices"][0]["message"].get("content")
except (KeyError, IndexError, TypeError, AttributeError):
return None
return content if isinstance(content, str) else None
def output_result(
result_type: str,
raw: dict[str, Any],
*,
prompt_used: str | None = None,
translated_prompt: str | None = None,
urls: list[str] | None = None,
status: str | None = None,
next_steps: list[str] | None = None,
raw_only: bool = False,
) -> None:
if raw_only:
print_json(raw)
return
summary: dict[str, Any] = {"type": result_type}
if status:
summary["status"] = status
if urls:
summary["urls"] = urls
if prompt_used:
summary["prompt_used"] = prompt_used
if translated_prompt:
summary["translated_prompt"] = translated_prompt
if next_steps:
summary["next_steps"] = next_steps
summary["raw"] = raw
print_json(summary)
def extract_image_urls(data: dict[str, Any]) -> list[str]:
urls = []
if isinstance(data.get("url"), str):
urls.append(data["url"])
if isinstance(data.get("image_url"), str):
urls.append(data["image_url"])
if isinstance(data.get("data"), list):
for item in data["data"]:
if isinstance(item, dict):
for key in ("url", "image_url"):
if isinstance(item.get(key), str):
urls.append(item[key])
return urls
def extract_video_urls(data: dict[str, Any]) -> list[str]:
urls = []
for key in ("video_url", "url", "remixed_from_video_id"):
value = data.get(key)
if isinstance(value, str) and value.startswith(("http://", "https://")):
urls.append(value)
if isinstance(data.get("data"), list):
for item in data["data"]:
if isinstance(item, dict):
urls.extend(extract_video_urls(item))
return list(dict.fromkeys(urls))
def video_lookup_id(data: dict[str, Any]) -> tuple[str | None, str | None]:
video_id = data.get("video_id")
if isinstance(video_id, str) and video_id:
return video_id, "video_id"
for key in ("task_id", "id"):
value = data.get(key)
if isinstance(value, str) and value:
return value, "task_id"
return None, None
def retrieve_video(identifier: str, model_name: str | None = VIDEO_MODEL) -> dict[str, Any]:
if identifier.startswith("video_"):
query = {"video_id": identifier}
if model_name:
query["model_name"] = model_name
return request_json("GET", "/agnesapi?" + urllib.parse.urlencode(query))
return request_json("GET", f"/v1/videos/{urllib.parse.quote(identifier, safe='')}")
def validate_size(value: str | None, name: str = "size") -> None:
if value and not SIZE_RE.match(value):
raise SystemExit(f"Invalid {name}: {value}. Expected WIDTHxHEIGHT, for example 1024x768.")
def validate_video_args(args: argparse.Namespace) -> None:
if args.num_frames is not None:
if args.num_frames > 441 or (args.num_frames - 1) % 8 != 0:
raise SystemExit("Invalid --num-frames: must be <= 441 and satisfy 8n + 1, for example 81 or 121.")
if args.frame_rate is not None and not (1 <= args.frame_rate <= 60):
raise SystemExit("Invalid --frame-rate: supported range is 1-60.")
for name in ("height", "width"):
value = getattr(args, name)
if value is not None and value <= 0:
raise SystemExit(f"Invalid --{name.replace('_', '-')}: must be a positive integer.")
def cmd_text(args: argparse.Namespace) -> None:
messages = []
if args.system:
messages.append({"role": "system", "content": args.system})
messages.append({"role": "user", "content": args.prompt})
payload: dict[str, Any] = {
"model": TEXT_MODEL,
"messages": messages,
"temperature": args.temperature,
"max_tokens": args.max_tokens,
}
if args.top_p is not None:
payload["top_p"] = args.top_p
if args.stream:
payload["stream"] = True
if args.tools_json:
payload["tools"] = parse_json_arg("--tools-json", args.tools_json)
if args.tool_choice_json:
payload["tool_choice"] = parse_json_arg("--tool-choice-json", args.tool_choice_json)
if args.stream:
print_json(stream_summary(payload))
else:
data = request_json("POST", "/v1/chat/completions", payload)
content = extract_text_content(data)
wrapped = {
"type": "text",
"content": content,
"raw": data,
}
print_json(data if args.raw else wrapped)
def cmd_image(args: argparse.Namespace) -> None:
validate_size(args.size)
prompt, translated_prompt = prepare_generation_prompt(args.prompt, not args.no_translate_prompt)
payload: dict[str, Any] = {
"model": IMAGE_MODEL,
"prompt": prompt,
}
if args.size:
payload["size"] = args.size
extra: dict[str, Any] = {"response_format": "url"}
if args.image:
extra["image"] = args.image
if extra:
payload["extra_body"] = extra
data = request_json("POST", "/v1/images/generations", payload)
urls = extract_image_urls(data)
output_result(
"image-to-image" if args.image else "text-to-image",
data,
prompt_used=prompt,
translated_prompt=translated_prompt,
urls=urls,
raw_only=args.raw,
)
def video_payload(args: argparse.Namespace) -> dict[str, Any]:
validate_video_args(args)
prompt, translated_prompt = prepare_generation_prompt(args.prompt, not args.no_translate_prompt)
args._prompt_used = prompt
args._translated_prompt = translated_prompt
payload: dict[str, Any] = {
"model": VIDEO_MODEL,
"prompt": prompt,
}
for name in (
"height",
"width",
"num_frames",
"frame_rate",
"num_inference_steps",
"seed",
"negative_prompt",
):
value = getattr(args, name)
if value is not None:
payload[name] = value
if args.mode:
payload["mode"] = args.mode
if args.image:
if len(args.image) == 1 and args.mode != "keyframes":
payload["image"] = args.image[0]
else:
payload["extra_body"] = {"image": args.image}
if args.mode:
payload["extra_body"]["mode"] = args.mode
return payload
def poll_video(identifier: str, timeout: int, interval: int) -> dict[str, Any]:
deadline = time.time() + timeout
last: dict[str, Any] = {}
while time.time() < deadline:
last = retrieve_video(identifier)
if last.get("error"):
raise SystemExit(f"Video {identifier} returned error: {json.dumps(last, ensure_ascii=False)}")
status = str(last.get("status", "")).lower()
progress = last.get("progress")
if status:
print(f"video {identifier}: status={status} progress={progress}", file=sys.stderr)
if status in {"completed", "failed"}:
return last
time.sleep(interval)
raise SystemExit(f"Timed out waiting for video {identifier}. Last response: {json.dumps(last)}")
def cmd_video(args: argparse.Namespace) -> None:
created = request_json("POST", "/v1/videos", video_payload(args))
if not args.poll:
identifier, id_kind = video_lookup_id(created)
next_steps = []
if identifier:
next_steps.append(f"python scripts/agnes_api.py video-get {identifier}")
next_steps.append(f"python scripts/agnes_api.py video-get {identifier} # repeat until status is completed")
output_result(
"video-task",
created,
prompt_used=getattr(args, "_prompt_used", None),
translated_prompt=getattr(args, "_translated_prompt", None),
status=str(created.get("status", "")) if created.get("status") is not None else None,
next_steps=next_steps,
raw_only=args.raw,
)
if id_kind == "task_id":
print("warning: create response did not include video_id; falling back to legacy task_id lookup", file=sys.stderr)
return
identifier, id_kind = video_lookup_id(created)
if not identifier:
raise SystemExit(f"Video create response did not include video_id, task_id, or id: {json.dumps(created)}")
if id_kind == "task_id":
print("warning: create response did not include video_id; falling back to legacy task_id lookup", file=sys.stderr)
data = poll_video(identifier, args.timeout, args.interval)
urls = extract_video_urls(data)
output_result(
"video-result",
data,
prompt_used=getattr(args, "_prompt_used", None),
translated_prompt=getattr(args, "_translated_prompt", None),
urls=urls,
status=str(data.get("status", "")) if data.get("status") is not None else None,
raw_only=args.raw,
)
def cmd_video_get(args: argparse.Namespace) -> None:
data = retrieve_video(args.identifier, None if args.no_model_name else args.model_name)
urls = extract_video_urls(data)
output_result(
"video-result",
data,
urls=urls,
status=str(data.get("status", "")) if data.get("status") is not None else None,
next_steps=[] if urls else [f"python scripts/agnes_api.py video-get {args.identifier}"],
raw_only=args.raw,
)
if data.get("error"):
raise SystemExit(1)
def require_ok(name: str, data: dict[str, Any], keys: tuple[str, ...]) -> None:
missing = [key for key in keys if key not in data]
if missing:
raise SystemExit(f"{name} response missing {missing}: {json.dumps(data)}")
print(f"{name}: ok")
def require_video_ok(name: str, data: dict[str, Any], completed: bool = False) -> None:
require_ok(name, data, ("id", "status"))
if data.get("error"):
raise SystemExit(f"{name} returned error: {json.dumps(data, ensure_ascii=False)}")
status = str(data.get("status", "")).lower()
if status == "failed":
raise SystemExit(f"{name} failed: {json.dumps(data, ensure_ascii=False)}")
if completed and status != "completed":
raise SystemExit(f"{name} did not complete: {json.dumps(data, ensure_ascii=False)}")
if completed and not extract_video_urls(data):
raise SystemExit(f"{name} completed without a video URL: {json.dumps(data, ensure_ascii=False)}")
def check_tool_call(name: str, data: dict[str, Any], strict: bool = False) -> None:
try:
tool_calls = data["choices"][0]["message"].get("tool_calls")
except (KeyError, IndexError, TypeError, AttributeError):
tool_calls = None
if not tool_calls:
message = f"{name}: request accepted, but response did not include tool_calls"
if strict:
raise SystemExit(f"{message}: {json.dumps(data, ensure_ascii=False)}")
print(message, file=sys.stderr)
return
print(f"{name}: ok")
def extract_image_url(data: dict[str, Any]) -> str:
candidates = extract_image_urls(data)
if not candidates:
raise SystemExit(f"Could not find image URL in response: {json.dumps(data, ensure_ascii=False)}")
return candidates[0]
def create_video_case(name: str, payload: dict[str, Any], args: argparse.Namespace) -> dict[str, Any]:
created = request_json("POST", "/v1/videos", payload)
require_video_ok(f"{name}-create", created)
identifier, id_kind = video_lookup_id(created)
if not identifier:
raise SystemExit(f"{name}-create response missing video_id/task_id/id: {json.dumps(created, ensure_ascii=False)}")
if id_kind == "task_id":
print(f"{name}-create: warning: falling back to legacy task_id lookup", file=sys.stderr)
retrieved = (
poll_video(identifier, args.video_timeout, args.video_interval)
if args.poll_video
else retrieve_video(identifier)
)
require_video_ok(f"{name}-get", retrieved, completed=args.poll_video)
return {"create": created, "get": retrieved}
VIDEO_CASES = ("text-to-video", "image-to-video", "multi-image", "keyframes")
def cmd_smoke_test(args: argparse.Namespace) -> None:
validate_size(args.image_size, "image-size")
validate_video_args(
argparse.Namespace(
num_frames=args.video_num_frames,
frame_rate=args.video_frame_rate,
height=args.video_height,
width=args.video_width,
)
)
text = request_json(
"POST",
"/v1/chat/completions",
{
"model": TEXT_MODEL,
"messages": [{"role": "user", "content": "Reply with exactly: Agnes text ok"}],
"max_tokens": 20,
"temperature": 0,
},
)
require_ok("text", text, ("choices",))
text_stream = stream_summary(
{
"model": TEXT_MODEL,
"messages": [{"role": "user", "content": "Reply with exactly: Agnes stream ok"}],
"max_tokens": 20,
"temperature": 0,
"stream": True,
}
)
if text_stream["events"] < 1 and not text_stream["done"]:
raise SystemExit(f"text-stream response did not look like SSE: {json.dumps(text_stream)}")
print("text-stream: ok")
text_tools = request_json(
"POST",
"/v1/chat/completions",
{
"model": TEXT_MODEL,
"messages": [{"role": "user", "content": "Use the get_test_value tool."}],
"max_tokens": 128,
"temperature": 0,
"tools": [
{
"type": "function",
"function": {
"name": "get_test_value",
"description": "Return a deterministic smoke test value.",
"parameters": {
"type": "object",
"properties": {
"label": {"type": "string", "description": "test label"}
},
"required": ["label"],
},
},
}
],
"tool_choice": {"type": "function", "function": {"name": "get_test_value"}},
},
)
check_tool_call("text-tools", text_tools, strict=args.strict_tools)
image_text = request_json(
"POST",
"/v1/images/generations",
{
"model": IMAGE_MODEL,
"prompt": "A simple red square icon centered on a white background",
"size": args.image_size,
"extra_body": {"response_format": "url"},
},
)
require_ok("image-text-to-image", image_text, ("data",))
generated_image_url = extract_image_url(image_text)
image_edit = None
edited_image_url = None
selected_cases = set(args.video_case or [])
needs_second_image = bool(selected_cases.intersection({"multi-image", "keyframes"}))
if args.include_image_edit or needs_second_image:
image_edit = request_json(
"POST",
"/v1/images/generations",
{
"model": IMAGE_MODEL,
"prompt": "Turn this into a clean blue square icon while preserving the centered composition",
"size": args.image_size,
"extra_body": {"image": [generated_image_url], "response_format": "url"},
},
)
require_ok("image-to-image", image_edit, ("data",))
edited_image_url = extract_image_url(image_edit)
video_common = {
"model": VIDEO_MODEL,
}
for key, value in (
("height", args.video_height),
("width", args.video_width),
("num_frames", args.video_num_frames),
("frame_rate", args.video_frame_rate),
):
if value is not None:
video_common[key] = value
video_results = {}
if "text-to-video" in selected_cases:
video_results["text_to_video"] = create_video_case(
"video-text-to-video",
{
**video_common,
"prompt": "A simple cinematic shot of a red square gently moving on a white background",
},
args,
)
if "image-to-video" in selected_cases:
video_results["image_to_video"] = create_video_case(
"video-image-to-video",
{
**video_common,
"prompt": "Animate the icon with subtle floating motion, stable centered composition",
"image": generated_image_url,
},
args,
)
if "multi-image" in selected_cases:
if not edited_image_url:
raise SystemExit("multi-image test requires an edited image URL")
video_results["multi_image"] = create_video_case(
"video-multi-image",
{
**video_common,
"prompt": "Create a smooth transformation from the first icon to the second icon, stable centered composition",
"extra_body": {"image": [generated_image_url, edited_image_url]},
},
args,
)
if "keyframes" in selected_cases:
if not edited_image_url:
raise SystemExit("keyframes test requires an edited image URL")
video_results["keyframes"] = create_video_case(
"video-keyframes",
{
**video_common,
"prompt": "Create a smooth keyframe transition between the two icons, stable centered composition",
"extra_body": {"image": [generated_image_url, edited_image_url], "mode": "keyframes"},
},
args,
)
print_json(
{
"text": text,
"text_stream": text_stream,
"text_tools": text_tools,
"image_text_to_image": image_text,
"image_to_image": image_edit,
"video": video_results,
}
)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Call Agnes AI generation APIs.")
sub = parser.add_subparsers(dest="command", required=True)
text = sub.add_parser("text", help="Create a chat completion.")
text.add_argument("--prompt", required=True)
text.add_argument("--system")
text.add_argument("--temperature", type=float, default=0.7)
text.add_argument("--top-p", type=float)
text.add_argument("--max-tokens", type=int, default=1024)
text.add_argument("--stream", action="store_true")
text.add_argument("--tools-json", help="JSON array for OpenAI-compatible tool definitions.")
text.add_argument("--tool-choice-json", help="JSON object/string for OpenAI-compatible tool_choice.")
text.add_argument("--raw", action="store_true", help="Print the raw provider response.")
text.set_defaults(func=cmd_text)
image = sub.add_parser("image", help="Generate or edit an image.")
image.add_argument("--prompt", required=True)
image.add_argument("--size", default="1024x768")
image.add_argument("--image", action="append", help="Input image URL. Repeat for multiple images.")
image.add_argument(
"--no-translate-prompt",
action="store_true",
help="Do not translate non-English prompts before sending to the image API.",
)
image.add_argument("--raw", action="store_true", help="Print the raw provider response.")
image.set_defaults(func=cmd_image)
video = sub.add_parser("video", help="Create a video task.")
video.add_argument("--prompt", required=True)
video.add_argument("--image", action="append", help="Input image URL. Repeat for multi-image or keyframes.")
video.add_argument("--mode", choices=("ti2vid", "keyframes"))
video.add_argument("--height", type=int)
video.add_argument("--width", type=int)
video.add_argument("--num-frames", type=int, default=121)
video.add_argument("--frame-rate", type=float, default=24)
video.add_argument("--num-inference-steps", type=int)
video.add_argument("--seed", type=int)
video.add_argument("--negative-prompt")
video.add_argument(
"--no-translate-prompt",
action="store_true",
help="Do not translate non-English prompts before sending to the video API.",
)
video.add_argument("--poll", action="store_true")
video.add_argument("--timeout", type=int, default=900)
video.add_argument("--interval", type=int, default=10)
video.add_argument("--raw", action="store_true", help="Print the raw provider response.")
video.set_defaults(func=cmd_video)
video_get = sub.add_parser("video-get", help="Retrieve a video result by video_id, or by legacy task_id.")
video_get.add_argument("identifier", help="Prefer video_id. Legacy task_id is still accepted.")
video_get.add_argument("--model-name", default=VIDEO_MODEL, help="Model name for video_id result lookup.")
video_get.add_argument("--no-model-name", action="store_true", help="Do not pass model_name for video_id lookup.")
video_get.add_argument("--raw", action="store_true", help="Print the raw provider response.")
video_get.set_defaults(func=cmd_video_get)
smoke = sub.add_parser("smoke-test", help="Run live text, image, and video API tests.")
smoke.add_argument("--image-size", default="1024x768")
smoke.add_argument("--video-height", type=int)
smoke.add_argument("--video-width", type=int)
smoke.add_argument("--video-num-frames", type=int, default=81)
smoke.add_argument("--video-frame-rate", type=float, default=24)
smoke.add_argument("--include-image-edit", action="store_true", help="Also test image-to-image editing.")
smoke.add_argument("--strict-tools", action="store_true", help="Fail if the tool-calling response has no tool_calls.")
smoke.add_argument("--poll-video", action="store_true")
smoke.add_argument("--video-timeout", type=int, default=900)
smoke.add_argument("--video-interval", type=int, default=10)
smoke.add_argument(
"--video-case",
action="append",
choices=VIDEO_CASES,
help="Video case to test. Repeat to test multiple cases. Omit to skip video creation.",
)
smoke.set_defaults(func=cmd_smoke_test)
return parser
def main() -> None:
args = build_parser().parse_args()
args.func(args)
if __name__ == "__main__":
main()