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 +36 -0
- package/bin/foxapi-imagegen-skill.js +140 -0
- package/package.json +18 -0
- package/skill/SKILL.md +124 -0
- package/skill/agents/openai.yaml +7 -0
- package/skill/scripts/foxapi_imagegen.py +453 -0
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,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""
|
|
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())
|