foxapi-imagegen-skill 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,36 @@
1
+ # FoxAPI Imagegen Skill CLI
2
+
3
+ This package installs the FoxAPI image generation skill for Codex.
4
+
5
+ ## Install from npm
6
+
7
+ ```bash
8
+ npx --yes foxapi-imagegen-skill install
9
+ ```
10
+
11
+ The shortest hosted command is:
12
+
13
+ ```bash
14
+ npx --yes https://course.foxapi.cn/f.tgz
15
+ ```
16
+
17
+ On Windows, the default destination is `%USERPROFILE%\\.codex\\skills\\foxapi-imagegen`.
18
+ On macOS and Linux, it is `~/.codex/skills/foxapi-imagegen`.
19
+
20
+ To replace an existing copy:
21
+
22
+ ```bash
23
+ npx --yes foxapi-imagegen-skill install --force
24
+ ```
25
+
26
+ The package does not contain API keys or generated images. The installed skill reads
27
+ `FOXAPI_API_KEY` or another supported credential source when it runs.
28
+
29
+ ## Run the installed skill
30
+
31
+ ```bash
32
+ python ~/.codex/skills/foxapi-imagegen/scripts/foxapi_imagegen.py generate --prompt "A clean product illustration"
33
+ ```
34
+
35
+ The skill calls `https://foxapi.cn/v1/responses` with the
36
+ `image_generation` tool and saves the returned Base64 image locally.
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env node
2
+
3
+ "use strict";
4
+
5
+ const fs = require("fs");
6
+ const os = require("os");
7
+ const path = require("path");
8
+
9
+ const packageRoot = path.resolve(__dirname, "..");
10
+ const sourceDir = path.join(packageRoot, "skill");
11
+
12
+ function printUsage() {
13
+ console.log(`FoxAPI Imagegen Skill installer
14
+
15
+ Usage:
16
+ npx --yes https://course.foxapi.cn/f.tgz
17
+ npx --yes foxapi-imagegen-skill install
18
+ npx --yes foxapi-imagegen-skill path
19
+ npx --yes foxapi-imagegen-skill uninstall --yes
20
+
21
+ Commands:
22
+ install Install the skill into CODEX_HOME/skills.
23
+ install --force Replace an existing FoxAPI Imagegen skill.
24
+ path Print the installation path.
25
+ uninstall --yes Remove the installed skill.
26
+
27
+ Options:
28
+ --codex-home <path> Override CODEX_HOME for this command.
29
+ --force Allow install to replace the target directory.
30
+ --yes Confirm uninstall.
31
+ `);
32
+ }
33
+
34
+ function fail(message) {
35
+ console.error(`Error: ${message}`);
36
+ process.exitCode = 1;
37
+ }
38
+
39
+ function parseArgs(argv) {
40
+ const positionals = [];
41
+ const options = {};
42
+ for (let index = 0; index < argv.length; index += 1) {
43
+ const value = argv[index];
44
+ if (value === "--force" || value === "--yes") {
45
+ options[value.slice(2)] = true;
46
+ continue;
47
+ }
48
+ if (value === "--help" || value === "-h") {
49
+ options.help = true;
50
+ continue;
51
+ }
52
+ if (value === "--version" || value === "-v") {
53
+ options.version = true;
54
+ continue;
55
+ }
56
+ if (value === "--codex-home") {
57
+ const next = argv[index + 1];
58
+ if (!next || next.startsWith("--")) {
59
+ throw new Error("--codex-home requires a path.");
60
+ }
61
+ options.codexHome = next;
62
+ index += 1;
63
+ continue;
64
+ }
65
+ if (value.startsWith("--")) {
66
+ throw new Error(`Unknown option: ${value}`);
67
+ }
68
+ positionals.push(value);
69
+ }
70
+ return { command: positionals[0] || "install", options };
71
+ }
72
+
73
+ function resolveCodexHome(options) {
74
+ const raw = options.codexHome || process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
75
+ return path.resolve(raw);
76
+ }
77
+
78
+ function targetPath(options) {
79
+ return path.join(resolveCodexHome(options), "skills", "foxapi-imagegen");
80
+ }
81
+
82
+ function validateSource() {
83
+ const required = [
84
+ path.join(sourceDir, "SKILL.md"),
85
+ path.join(sourceDir, "agents", "openai.yaml"),
86
+ path.join(sourceDir, "scripts", "foxapi_imagegen.py"),
87
+ ];
88
+ for (const file of required) {
89
+ if (!fs.existsSync(file)) {
90
+ throw new Error(`Package is missing ${path.relative(packageRoot, file)}.`);
91
+ }
92
+ }
93
+ }
94
+
95
+ function install(options) {
96
+ validateSource();
97
+ const destination = targetPath(options);
98
+ if (fs.existsSync(destination)) {
99
+ if (!options.force) {
100
+ throw new Error(`Target already exists: ${destination}. Use install --force to replace it.`);
101
+ }
102
+ fs.rmSync(destination, { recursive: true, force: true });
103
+ }
104
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
105
+ fs.cpSync(sourceDir, destination, { recursive: true });
106
+ console.log(`Installed FoxAPI Imagegen Skill to ${destination}`);
107
+ console.log("Restart Codex to load the new skill.");
108
+ }
109
+
110
+ function uninstall(options) {
111
+ const destination = targetPath(options);
112
+ if (!fs.existsSync(destination)) {
113
+ console.log(`Skill is not installed at ${destination}`);
114
+ return;
115
+ }
116
+ if (!options.yes) {
117
+ throw new Error("Uninstall is destructive. Add --yes to confirm.");
118
+ }
119
+ fs.rmSync(destination, { recursive: true, force: true });
120
+ console.log(`Removed FoxAPI Imagegen Skill from ${destination}`);
121
+ }
122
+
123
+ try {
124
+ const { command, options } = parseArgs(process.argv.slice(2));
125
+ if (options.version) {
126
+ console.log(require(path.join(packageRoot, "package.json")).version);
127
+ } else if (options.help || command === "help") {
128
+ printUsage();
129
+ } else if (command === "path") {
130
+ console.log(targetPath(options));
131
+ } else if (command === "install") {
132
+ install(options);
133
+ } else if (command === "uninstall") {
134
+ uninstall(options);
135
+ } else {
136
+ throw new Error(`Unknown command: ${command}`);
137
+ }
138
+ } catch (error) {
139
+ fail(error instanceof Error ? error.message : String(error));
140
+ }
package/package.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "foxapi-imagegen-skill",
3
+ "version": "0.1.1",
4
+ "description": "Install the FoxAPI image generation Codex skill.",
5
+ "bin": {
6
+ "foxapi-imagegen-skill": "bin/foxapi-imagegen-skill.js"
7
+ },
8
+ "files": [
9
+ "bin",
10
+ "skill",
11
+ "README.md"
12
+ ],
13
+ "engines": {
14
+ "node": ">=16"
15
+ },
16
+ "license": "MIT",
17
+ "preferGlobal": false
18
+ }
package/skill/SKILL.md ADDED
@@ -0,0 +1,124 @@
1
+ ---
2
+ name: foxapi-imagegen
3
+ description: Use when the user asks Codex to generate or edit an image through FoxAPI instead of the built-in image_gen tool, including poster, avatar, cover, illustration, website promotional image, text-to-image, image-to-image, reference-image generation, attached-image edits, or "use image2/gpt-image-2 through foxapi.cn". This reusable skill calls FoxAPI's OpenAI-compatible Responses API image_generation tool and saves the returned Base64 image locally.
4
+ ---
5
+
6
+ # FoxAPI Imagegen
7
+
8
+ Generate or edit images through FoxAPI's OpenAI-compatible Responses API:
9
+
10
+ ```text
11
+ POST https://foxapi.cn/v1/responses
12
+ tools: [{ "type": "image_generation", "action": "generate" }]
13
+ ```
14
+
15
+ ## Sharing / Installation
16
+
17
+ This skill is reusable. To share it, distribute only the `foxapi-imagegen` folder or a zip of that folder. Do not include any `outputs/`, `.codex/auth.json`, API keys, or generated images.
18
+
19
+ To install, copy the `foxapi-imagegen` folder into the user's Codex skills directory and start a new Codex task:
20
+
21
+ - Windows: `%USERPROFILE%\.codex\skills\foxapi-imagegen`
22
+ - macOS/Linux: `~/.codex/skills/foxapi-imagegen`
23
+
24
+ ## Routing
25
+
26
+ When this skill is selected:
27
+
28
+ 1. Do not use the built-in `image_gen` tool.
29
+ 2. Do not use the system `.system/imagegen` skill workflow.
30
+ 3. Run `scripts/foxapi_imagegen.py`.
31
+ 4. Use FoxAPI by default: `https://foxapi.cn/v1/responses`.
32
+ 5. Pass user-provided reference images with `--image` or `--image-url` when available.
33
+ 6. Save the decoded image file locally and show it inline in the final response.
34
+
35
+ ## Credentials
36
+
37
+ Never print API keys.
38
+
39
+ The script resolves credentials in this order:
40
+
41
+ 1. `--api-key`
42
+ 2. `FOXAPI_API_KEY`
43
+ 3. `FOXAPI_KEY`
44
+ 4. `OPENAI_API_KEY`
45
+ 5. Codex `auth.json`, looking for API-key fields such as `OPENAI_API_KEY`, `FOXAPI_API_KEY`, or `api_key`
46
+
47
+ This means a Codex desktop session logged in with an API key can usually be used without passing a key in chat.
48
+
49
+ Users who are not logged in through Codex can set:
50
+
51
+ ```bash
52
+ FOXAPI_API_KEY="<their-key>"
53
+ ```
54
+
55
+ Optional environment overrides:
56
+
57
+ - `FOXAPI_RESPONSES_URL`: full endpoint, for example `https://foxapi.cn/v1/responses`.
58
+ - `FOXAPI_BASE_URL`: base URL, for example `https://foxapi.cn/v1`.
59
+ - `FOXAPI_RESPONSES_MODEL`, `FOXAPI_IMAGE_MODEL`, or `FOXAPI_MODEL`: model used for the Responses call.
60
+ - `FOXAPI_IMAGE_SIZE`: requested size, for example `1024x1024`.
61
+ - `FOXAPI_IMAGE_QUALITY`: requested quality.
62
+ - `FOXAPI_IMAGE_OUTPUT_FORMAT`: `png`, `jpeg`, or `webp`.
63
+ - `FOXAPI_IMAGE_OUTPUT_DIR`: output folder.
64
+
65
+ ## Commands
66
+
67
+ Text-to-image:
68
+
69
+ ```bash
70
+ python scripts/foxapi_imagegen.py generate --prompt "<prompt>" --cwd "<current workspace>"
71
+ ```
72
+
73
+ Reference-image generation / edit:
74
+
75
+ ```bash
76
+ python scripts/foxapi_imagegen.py generate --prompt "<prompt>" --image "<attached image path>" --cwd "<current workspace>"
77
+ ```
78
+
79
+ Useful options:
80
+
81
+ - `--output-dir "<folder>"`: save somewhere specific.
82
+ - `--model "gpt-5.5"`: Responses model, default `gpt-5.5`.
83
+ - `--size "1024x1024"`: requested image size.
84
+ - `--quality "high"`: requested quality.
85
+ - `--image "<path>"`: local reference image path; repeat for multiple reference images.
86
+ - `--image-url "<url-or-data-url>"`: remote reference image or existing data URL; repeat for multiple reference images.
87
+ - `--endpoint "https://foxapi.cn/v1/responses"`: override the full Responses endpoint.
88
+ - `--base-url "https://foxapi.cn/v1"`: override the default base URL.
89
+ - `--n 2`: generate multiple images by making repeated requests.
90
+
91
+ For local attachments supplied by Codex, pass the absolute file path exactly as provided. The script converts it to a `data:image/...;base64,...` `input_image` item and sends it together with the prompt.
92
+
93
+ ## Output Location
94
+
95
+ Choose the output directory in this order:
96
+
97
+ 1. `--output-dir`
98
+ 2. `FOXAPI_IMAGE_OUTPUT_DIR`
99
+ 3. `<current workspace>/outputs/foxapi-imagegen/`
100
+
101
+ ## Display
102
+
103
+ The script prints JSON. If `ok` is true:
104
+
105
+ 1. Insert each returned `markdown` image line directly in the reply.
106
+ 2. Include the `saved_markdown` lines.
107
+ 3. Include the `folder_markdown` line.
108
+ 4. Mention `actual_size` and `actual_quality` if they differ from the requested values.
109
+ 5. Do not show Base64 or raw SSE data.
110
+
111
+ ## Notes
112
+
113
+ FoxAPI's Responses image tool may return an actual size or quality different from the requested `--size` and `--quality`. Report the actual values from the JSON response when present.
114
+
115
+ ## Errors
116
+
117
+ Use friendly Chinese explanations:
118
+
119
+ - Missing key: ask the user to confirm Codex is logged in with an API key, or set `FOXAPI_API_KEY` / `OPENAI_API_KEY`.
120
+ - 401: key invalid, disabled, expired, or missing.
121
+ - 403: key lacks permission or account access.
122
+ - 402/quota/balance: balance or quota is insufficient.
123
+ - Missing image result: the API did not return an `image_generation_call.result` field.
124
+ - Unreadable reference image: the referenced local file path is missing or cannot be read.
@@ -0,0 +1,7 @@
1
+ interface:
2
+ display_name: "FoxAPI Imagegen"
3
+ short_description: "Reusable FoxAPI Responses image generation"
4
+ default_prompt: "Use $foxapi-imagegen to generate or edit an image through FoxAPI."
5
+
6
+ policy:
7
+ allow_implicit_invocation: true
@@ -0,0 +1,453 @@
1
+ #!/usr/bin/env python3
2
+ """Generate images through FoxAPI Responses image_generation."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import base64
8
+ import json
9
+ import mimetypes
10
+ import os
11
+ import re
12
+ import sys
13
+ import time
14
+ import urllib.error
15
+ import urllib.request
16
+ from pathlib import Path
17
+ from typing import Any, Iterable
18
+
19
+
20
+ DEFAULT_BASE_URL = "https://foxapi.cn/v1"
21
+ DEFAULT_MODEL = "gpt-5.5"
22
+ DEFAULT_SIZE = "1024x1024"
23
+ DEFAULT_QUALITY = "high"
24
+ DEFAULT_OUTPUT_FORMAT = "png"
25
+ OUTPUT_SUBDIR = Path("outputs") / "foxapi-imagegen"
26
+ USER_AGENT = "codex-foxapi-imagegen/1.0"
27
+
28
+
29
+ class ImagegenError(Exception):
30
+ def __init__(self, error_type: str, message: str) -> None:
31
+ super().__init__(message)
32
+ self.error_type = error_type
33
+ self.message = message
34
+
35
+
36
+ def main() -> int:
37
+ parser = build_parser()
38
+ args = parser.parse_args()
39
+ try:
40
+ result = run(args)
41
+ except ImagegenError as exc:
42
+ result = {"ok": False, "error_type": exc.error_type, "message": exc.message}
43
+ except Exception as exc: # noqa: BLE001 - command boundary
44
+ result = {
45
+ "ok": False,
46
+ "error_type": "unexpected_error",
47
+ "message": f"Unexpected image generation error: {exc}",
48
+ }
49
+ print(json.dumps(result, ensure_ascii=False, indent=2))
50
+ return 0 if result.get("ok") else 1
51
+
52
+
53
+ def build_parser() -> argparse.ArgumentParser:
54
+ parser = argparse.ArgumentParser(description="Generate images through FoxAPI Responses.")
55
+ subparsers = parser.add_subparsers(dest="mode", required=True)
56
+
57
+ generate = subparsers.add_parser("generate", help="Text-to-image or reference-image generation.")
58
+ generate.add_argument("--prompt", required=True, help="Image prompt.")
59
+ generate.add_argument(
60
+ "--image",
61
+ action="append",
62
+ default=[],
63
+ help="Reference image file path. Can be repeated.",
64
+ )
65
+ generate.add_argument(
66
+ "--image-url",
67
+ action="append",
68
+ default=[],
69
+ help="Reference image URL or data URL. Can be repeated.",
70
+ )
71
+ generate.add_argument("--cwd", help="Current Codex workspace folder.")
72
+ generate.add_argument("--output-dir", help="Explicit output folder.")
73
+ generate.add_argument(
74
+ "--model",
75
+ default=first_env(["FOXAPI_RESPONSES_MODEL", "FOXAPI_IMAGE_MODEL", "FOXAPI_MODEL"], DEFAULT_MODEL),
76
+ help="Responses model.",
77
+ )
78
+ generate.add_argument(
79
+ "--size",
80
+ default=first_env(["FOXAPI_IMAGE_SIZE"], DEFAULT_SIZE),
81
+ help="Requested size, e.g. 1024x1024.",
82
+ )
83
+ generate.add_argument(
84
+ "--quality",
85
+ default=first_env(["FOXAPI_IMAGE_QUALITY"], DEFAULT_QUALITY),
86
+ help="Requested quality.",
87
+ )
88
+ generate.add_argument("--background", help="Optional image background setting.")
89
+ generate.add_argument("--api-key", help="API key override. Prefer environment variables.")
90
+ generate.add_argument("--endpoint", help="Full Responses endpoint, e.g. https://foxapi.cn/v1/responses.")
91
+ generate.add_argument("--base-url", help="Base URL, default https://foxapi.cn/v1.")
92
+ generate.add_argument(
93
+ "--output-format",
94
+ default=first_env(["FOXAPI_IMAGE_OUTPUT_FORMAT"], DEFAULT_OUTPUT_FORMAT),
95
+ )
96
+ generate.add_argument("--n", type=int, default=1, help="Number of images to generate.")
97
+ generate.add_argument("--timeout", type=int, default=240)
98
+ generate.add_argument("--mock-b64", help="Offline test hook: save this Base64 image.")
99
+ return parser
100
+
101
+
102
+ def run(args: argparse.Namespace) -> dict[str, Any]:
103
+ validate_args(args)
104
+ output_dir = resolve_output_dir(args)
105
+ output_dir.mkdir(parents=True, exist_ok=True)
106
+
107
+ if args.mock_b64:
108
+ api_key = ""
109
+ credential_source = "mock_b64"
110
+ else:
111
+ api_key, credential_source = resolve_api_key(args)
112
+
113
+ saved: list[dict[str, Any]] = []
114
+ for index in range(args.n):
115
+ if args.mock_b64:
116
+ item = {
117
+ "result": args.mock_b64,
118
+ "output_format": normalize_output_format(args.output_format),
119
+ "status": "completed",
120
+ "action": "generate",
121
+ }
122
+ else:
123
+ item = request_image(args, api_key)
124
+ saved.append(save_image_item(item, args, output_dir, index))
125
+
126
+ paths = [entry["path"] for entry in saved]
127
+ markdown = [to_markdown_image(path) for path in paths]
128
+ saved_markdown = [f"Saved to: {to_markdown_link(path, path)}" for path in paths]
129
+ folder = to_display_path(output_dir)
130
+
131
+ return {
132
+ "ok": True,
133
+ "mode": "generate",
134
+ "provider": "foxapi",
135
+ "endpoint": resolve_responses_url(args),
136
+ "model": args.model,
137
+ "credential_source": credential_source,
138
+ "requested_size": args.size,
139
+ "requested_quality": args.quality,
140
+ "reference_image_count": reference_image_count(args),
141
+ "images": saved,
142
+ "paths": paths,
143
+ "markdown": markdown,
144
+ "saved_markdown": saved_markdown,
145
+ "folder_markdown": to_markdown_link("Open folder", folder),
146
+ }
147
+
148
+
149
+ def validate_args(args: argparse.Namespace) -> None:
150
+ if args.n < 1 or args.n > 4:
151
+ raise ImagegenError("invalid_n", "--n must be between 1 and 4.")
152
+ if not re.fullmatch(r"\d+x\d+", args.size):
153
+ raise ImagegenError("invalid_size", "Use WIDTHxHEIGHT for --size, for example 1024x1024.")
154
+ if args.output_format.lower() not in {"png", "jpg", "jpeg", "webp"}:
155
+ raise ImagegenError("invalid_output_format", "--output-format must be png, jpg, jpeg, or webp.")
156
+ if args.timeout < 1:
157
+ raise ImagegenError("invalid_timeout", "--timeout must be positive.")
158
+ for image in args.image:
159
+ path = Path(image).expanduser()
160
+ if not path.is_file():
161
+ raise ImagegenError("unreadable_reference_image", f"Reference image is not readable: {image}")
162
+
163
+
164
+ def resolve_output_dir(args: argparse.Namespace) -> Path:
165
+ if args.output_dir:
166
+ return Path(args.output_dir).expanduser()
167
+ env_dir = os.getenv("FOXAPI_IMAGE_OUTPUT_DIR")
168
+ if env_dir:
169
+ return Path(env_dir).expanduser()
170
+ cwd = Path(args.cwd).expanduser() if args.cwd else Path.cwd()
171
+ return cwd / OUTPUT_SUBDIR
172
+
173
+
174
+ def resolve_api_key(args: argparse.Namespace) -> tuple[str, str]:
175
+ candidates = [
176
+ ("--api-key", args.api_key),
177
+ ("FOXAPI_API_KEY", os.getenv("FOXAPI_API_KEY")),
178
+ ("FOXAPI_KEY", os.getenv("FOXAPI_KEY")),
179
+ ("OPENAI_API_KEY", os.getenv("OPENAI_API_KEY")),
180
+ ]
181
+ for source, value in candidates:
182
+ if value:
183
+ return value, source
184
+
185
+ auth_key = read_codex_auth_json_key()
186
+ if auth_key:
187
+ return auth_key, "codex_auth_json"
188
+
189
+ raise ImagegenError(
190
+ "missing_api_key",
191
+ "No API key found. Log in to Codex with an API key, or set FOXAPI_API_KEY / OPENAI_API_KEY.",
192
+ )
193
+
194
+
195
+ def read_codex_auth_json_key() -> str | None:
196
+ paths: list[Path] = []
197
+ codex_home = os.getenv("CODEX_HOME")
198
+ if codex_home:
199
+ paths.append(Path(codex_home).expanduser() / "auth.json")
200
+ paths.append(Path.home() / ".codex" / "auth.json")
201
+
202
+ for path in paths:
203
+ try:
204
+ data = json.loads(path.read_text(encoding="utf-8"))
205
+ except (OSError, json.JSONDecodeError, UnicodeDecodeError):
206
+ continue
207
+ value = find_api_key(data)
208
+ if value:
209
+ return value
210
+ return None
211
+
212
+
213
+ def find_api_key(value: Any) -> str | None:
214
+ names = {"OPENAI_API_KEY", "FOXAPI_API_KEY", "FOXAPI_KEY", "api_key", "openai_api_key", "foxapi_api_key"}
215
+ if isinstance(value, dict):
216
+ for key, item in value.items():
217
+ if key in names and isinstance(item, str) and item.strip():
218
+ return item.strip()
219
+ for item in value.values():
220
+ found = find_api_key(item)
221
+ if found:
222
+ return found
223
+ elif isinstance(value, list):
224
+ for item in value:
225
+ found = find_api_key(item)
226
+ if found:
227
+ return found
228
+ return None
229
+
230
+
231
+ def normalize_base_url(raw: str | None) -> str:
232
+ base_url = raw or os.getenv("FOXAPI_BASE_URL") or os.getenv("OPENAI_BASE_URL") or DEFAULT_BASE_URL
233
+ base_url = base_url.rstrip("/")
234
+ if base_url.endswith("/v1"):
235
+ return base_url
236
+ return f"{base_url}/v1"
237
+
238
+
239
+ def resolve_responses_url(args: argparse.Namespace) -> str:
240
+ endpoint = args.endpoint or os.getenv("FOXAPI_RESPONSES_URL")
241
+ if endpoint:
242
+ endpoint = endpoint.rstrip("/")
243
+ if endpoint.endswith("/responses"):
244
+ return endpoint
245
+ if endpoint.endswith("/v1"):
246
+ return f"{endpoint}/responses"
247
+ return f"{endpoint}/v1/responses"
248
+ return f"{normalize_base_url(args.base_url)}/responses"
249
+
250
+
251
+ def first_env(names: list[str], fallback: str) -> str:
252
+ for name in names:
253
+ value = os.getenv(name)
254
+ if value:
255
+ return value
256
+ return fallback
257
+
258
+
259
+ def normalize_output_format(value: str) -> str:
260
+ fmt = value.lower()
261
+ return "jpeg" if fmt == "jpg" else fmt
262
+
263
+
264
+ def request_image(args: argparse.Namespace, api_key: str) -> dict[str, Any]:
265
+ url = resolve_responses_url(args)
266
+ tool: dict[str, Any] = {
267
+ "type": "image_generation",
268
+ "action": "generate",
269
+ "size": args.size,
270
+ "quality": args.quality,
271
+ }
272
+ if args.background:
273
+ tool["background"] = args.background
274
+ if args.output_format:
275
+ tool["output_format"] = normalize_output_format(args.output_format)
276
+
277
+ payload = {
278
+ "model": args.model,
279
+ "input": build_input(args),
280
+ "tools": [tool],
281
+ "tool_choice": {"type": "image_generation"},
282
+ "stream": True,
283
+ }
284
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
285
+ request = urllib.request.Request(
286
+ url=url,
287
+ data=body,
288
+ method="POST",
289
+ headers={
290
+ "Authorization": f"Bearer {api_key}",
291
+ "Content-Type": "application/json",
292
+ "Accept": "text/event-stream",
293
+ "User-Agent": USER_AGENT,
294
+ },
295
+ )
296
+ try:
297
+ with urllib.request.urlopen(request, timeout=args.timeout) as response:
298
+ return read_image_item_from_sse(response)
299
+ except urllib.error.HTTPError as exc:
300
+ detail = safe_read_error(exc)
301
+ raise ImagegenError("http_error", f"FoxAPI returned HTTP {exc.code}: {detail}") from exc
302
+ except urllib.error.URLError as exc:
303
+ raise ImagegenError("network_error", f"Could not reach FoxAPI: {exc.reason}") from exc
304
+
305
+
306
+ def build_input(args: argparse.Namespace) -> str | list[dict[str, Any]]:
307
+ if not args.image and not args.image_url:
308
+ return args.prompt
309
+
310
+ content: list[dict[str, str]] = [{"type": "input_text", "text": args.prompt}]
311
+ for image in args.image:
312
+ content.append({"type": "input_image", "image_url": encode_image_file_as_data_url(image)})
313
+ for image_url in args.image_url:
314
+ content.append({"type": "input_image", "image_url": image_url})
315
+
316
+ return [{"role": "user", "content": content}]
317
+
318
+
319
+ def encode_image_file_as_data_url(image: str) -> str:
320
+ path = Path(image).expanduser()
321
+ try:
322
+ image_bytes = path.read_bytes()
323
+ except OSError as exc:
324
+ raise ImagegenError("unreadable_reference_image", f"Could not read reference image: {image}") from exc
325
+
326
+ mime_type = mimetypes.guess_type(path.name)[0] or "image/png"
327
+ b64 = base64.b64encode(image_bytes).decode("ascii")
328
+ return f"data:{mime_type};base64,{b64}"
329
+
330
+
331
+ def reference_image_count(args: argparse.Namespace) -> int:
332
+ return len(args.image) + len(args.image_url)
333
+
334
+
335
+ def read_image_item_from_sse(lines: Iterable[bytes]) -> dict[str, Any]:
336
+ event_name: str | None = None
337
+ data_lines: list[str] = []
338
+ last_error: Any = None
339
+ last_item: dict[str, Any] | None = None
340
+
341
+ def process_event(event: str | None, data: str) -> None:
342
+ nonlocal last_error, last_item
343
+ if not data or data == "[DONE]":
344
+ return
345
+ try:
346
+ obj = json.loads(data)
347
+ except json.JSONDecodeError:
348
+ return
349
+ error = obj.get("error")
350
+ if error:
351
+ last_error = error
352
+ for item in find_image_items(obj):
353
+ last_item = item
354
+
355
+ for raw in lines:
356
+ line = raw.decode("utf-8", errors="replace").rstrip("\r\n")
357
+ if line == "":
358
+ process_event(event_name, "\n".join(data_lines).strip())
359
+ event_name = None
360
+ data_lines = []
361
+ continue
362
+ if line.startswith("event:"):
363
+ event_name = line[len("event:") :].strip()
364
+ elif line.startswith("data:"):
365
+ data_lines.append(line[len("data:") :].lstrip())
366
+
367
+ if data_lines:
368
+ process_event(event_name, "\n".join(data_lines).strip())
369
+
370
+ if last_item and last_item.get("result"):
371
+ return last_item
372
+ if last_error:
373
+ raise ImagegenError("api_error", format_api_error(last_error))
374
+ raise ImagegenError("missing_image_result", "FoxAPI response did not contain image_generation_call.result.")
375
+
376
+
377
+ def find_image_items(value: Any) -> list[dict[str, Any]]:
378
+ found: list[dict[str, Any]] = []
379
+ if isinstance(value, dict):
380
+ if value.get("type") == "image_generation_call" and value.get("result"):
381
+ found.append(value)
382
+ for item in value.values():
383
+ found.extend(find_image_items(item))
384
+ elif isinstance(value, list):
385
+ for item in value:
386
+ found.extend(find_image_items(item))
387
+ return found
388
+
389
+
390
+ def save_image_item(item: dict[str, Any], args: argparse.Namespace, output_dir: Path, index: int) -> dict[str, Any]:
391
+ b64 = item.get("result")
392
+ if not isinstance(b64, str) or not b64:
393
+ raise ImagegenError("missing_image_result", "Image item has no Base64 result.")
394
+ try:
395
+ image_bytes = base64.b64decode(b64)
396
+ except Exception as exc: # noqa: BLE001
397
+ raise ImagegenError("decode_error", f"Could not decode image Base64: {exc}") from exc
398
+
399
+ output_format = normalize_output_format(str(item.get("output_format") or args.output_format))
400
+ suffix = "jpg" if output_format == "jpeg" else output_format
401
+ timestamp = f"{time.strftime('%Y%m%d-%H%M%S')}-{str(time.time_ns())[-6:]}"
402
+ slug = slugify(args.prompt)
403
+ path = output_dir / f"{timestamp}-{index + 1}-{slug}.{suffix}"
404
+ path.write_bytes(image_bytes)
405
+
406
+ return {
407
+ "path": to_display_path(path),
408
+ "bytes": len(image_bytes),
409
+ "item_id": item.get("id"),
410
+ "status": item.get("status"),
411
+ "action": item.get("action"),
412
+ "actual_size": item.get("size"),
413
+ "actual_quality": item.get("quality"),
414
+ "output_format": output_format,
415
+ }
416
+
417
+
418
+ def slugify(value: str) -> str:
419
+ value = value.strip().lower()
420
+ value = re.sub(r"[^a-z0-9]+", "-", value)
421
+ value = re.sub(r"-{2,}", "-", value).strip("-")
422
+ return value[:48] or "image"
423
+
424
+
425
+ def to_display_path(path: Path | str) -> str:
426
+ return str(Path(path).resolve()).replace("\\", "/")
427
+
428
+
429
+ def to_markdown_image(path: str) -> str:
430
+ return f"![Generated image]({path})"
431
+
432
+
433
+ def to_markdown_link(label: str, path: str) -> str:
434
+ return f"[{label}]({path})"
435
+
436
+
437
+ def safe_read_error(exc: urllib.error.HTTPError) -> str:
438
+ try:
439
+ body = exc.read().decode("utf-8", errors="replace")
440
+ except Exception: # noqa: BLE001
441
+ return exc.reason
442
+ return body[:2000]
443
+
444
+
445
+ def format_api_error(error: Any) -> str:
446
+ if isinstance(error, dict):
447
+ message = error.get("message") or error.get("type") or json.dumps(error, ensure_ascii=False)
448
+ return str(message)
449
+ return str(error)
450
+
451
+
452
+ if __name__ == "__main__":
453
+ raise SystemExit(main())