first commit
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
# image-gen-custom-project
|
||||
|
||||
这是一个面向 Codex 的本地技能包仓库,核心内容是 `image-gen-custom`:它通过兼容 OpenAI 的 Images API 生成图片,并把结果稳定地保存为本地 `PNG`、`JPEG`、`GIF` 或 `WebP` 文件。
|
||||
|
||||
## 仓库内容
|
||||
|
||||
```text
|
||||
skill/image-gen-custom/
|
||||
├─ SKILL.md
|
||||
├─ agents/openai.yaml
|
||||
└─ scripts/
|
||||
├─ configure.ps1 / configure.sh
|
||||
├─ generate-image.ps1 / generate-image.sh
|
||||
├─ install-skill.ps1 / install-skill.sh
|
||||
└─ 其他辅助脚本
|
||||
```
|
||||
|
||||
## 主要能力
|
||||
|
||||
- 支持自定义 OpenAI-compatible API base URL
|
||||
- 支持指定模型、尺寸、质量和输出路径
|
||||
- 自动校验返回内容是否为有效图片
|
||||
- 以原子写入方式保存输出文件
|
||||
- Windows 上使用 DPAPI 保存密钥,macOS/Linux 使用系统 keyring
|
||||
|
||||
## 安装
|
||||
|
||||
在对应平台运行安装脚本:
|
||||
|
||||
```powershell
|
||||
skill\image-gen-custom\scripts\install-skill.ps1
|
||||
```
|
||||
|
||||
```bash
|
||||
bash skill/image-gen-custom/scripts/install-skill.sh
|
||||
```
|
||||
|
||||
如需覆盖已有安装,可加 `-Force` 或 `--force`。
|
||||
|
||||
## 配置
|
||||
|
||||
首次使用前先配置 API 基础地址和密钥:
|
||||
|
||||
```powershell
|
||||
skill\image-gen-custom\scripts\configure.ps1
|
||||
```
|
||||
|
||||
```bash
|
||||
bash skill/image-gen-custom/scripts/configure.sh
|
||||
```
|
||||
|
||||
也可以临时使用环境变量:
|
||||
|
||||
- `OPENAI_API_KEY`
|
||||
- `OPENAI_BASE_URL`
|
||||
|
||||
## 生成图片
|
||||
|
||||
Windows:
|
||||
|
||||
```powershell
|
||||
skill\image-gen-custom\scripts\generate-image.ps1 `
|
||||
-Prompt "A concise prompt..." `
|
||||
-Out "outputs\generated-image.png" `
|
||||
-Size "1024x1024" `
|
||||
-Quality "low" `
|
||||
-Force
|
||||
```
|
||||
|
||||
macOS / Linux:
|
||||
|
||||
```bash
|
||||
bash skill/image-gen-custom/scripts/generate-image.sh \
|
||||
--prompt "A concise prompt..." \
|
||||
--out outputs/generated-image.png \
|
||||
--size 1024x1024 \
|
||||
--quality low \
|
||||
--force
|
||||
```
|
||||
|
||||
默认模型是 `gpt-image-2`,可通过 `-Model` / `--model` 覆盖。
|
||||
|
||||
## 运行时说明
|
||||
|
||||
- 图片 API 支持 `b64_json` 或图片 URL 返回
|
||||
- URL 下载上限为 50 MB
|
||||
- 输出会先写入临时文件,再替换到目标路径
|
||||
- 可通过 `CODEX_IMAGEGEN_VENV` 指定私有虚拟环境位置
|
||||
- 可通过 `CODEX_IMAGEGEN_PYTHON` 指定用于引导环境的 Python
|
||||
|
||||
## 安全提示
|
||||
|
||||
- 不要把密钥写进脚本、提交记录或这个 README
|
||||
- Windows 密钥保存到 DPAPI 保护的本地文件
|
||||
- 其他平台保存到系统 keyring
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
scripts/connection.local.json
|
||||
*.env
|
||||
**/__pycache__/
|
||||
*.py[cod]
|
||||
venv/
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
name: image-gen-custom
|
||||
description: Generate images through any OpenAI-compatible Images API and save reproducible local PNG, JPEG, GIF, or WebP files. Use when Codex needs a custom image endpoint, explicit model and output path, or a dependable cross-platform image generation workflow.
|
||||
---
|
||||
|
||||
# Image Gen Custom
|
||||
|
||||
Use `scripts/generate-image.ps1` on Windows or `scripts/generate-image.sh` on macOS/Linux. The wrappers create or reuse a private virtual environment; generation uses only the Python standard library. The Python core reads the API key, calls the Images API, validates the returned image, and writes it atomically.
|
||||
|
||||
```powershell
|
||||
& "$env:USERPROFILE\.codex\skills\image-gen-custom\scripts\generate-image.ps1" `
|
||||
-Prompt "A concise prompt..." `
|
||||
-Out "outputs\generated-image.png" `
|
||||
-Size "1024x1024" `
|
||||
-Quality "low" `
|
||||
-Force
|
||||
```
|
||||
|
||||
```bash
|
||||
"$HOME/.codex/skills/image-gen-custom/scripts/generate-image.sh" \
|
||||
--prompt "A concise prompt..." \
|
||||
--out outputs/generated-image.png \
|
||||
--size 1024x1024 \
|
||||
--quality low \
|
||||
--force
|
||||
```
|
||||
|
||||
## Credentials
|
||||
|
||||
Run the matching `configure` wrapper once. It prompts for the API base URL first, then prompts for the key in visible text so you can paste it. On Windows, the key is saved in a DPAPI-protected local file; on macOS and Linux, it uses the platform credential store.
|
||||
|
||||
```powershell
|
||||
& "$env:USERPROFILE\.codex\skills\image-gen-custom\scripts\configure.ps1"
|
||||
```
|
||||
|
||||
```bash
|
||||
"$HOME/.codex/skills/image-gen-custom/scripts/configure.sh"
|
||||
```
|
||||
|
||||
The URL and Windows key file are saved under the local Codex shared configuration folder. The normal image command then needs no URL argument. An explicit `-BaseUrl` on the image command still overrides the saved value.
|
||||
|
||||
Set `OPENAI_API_KEY` instead when an ephemeral environment variable is preferred. Set `OPENAI_BASE_URL` for a custom OpenAI-compatible root; `/v1` is appended when omitted. Never put a key in `SKILL.md`, scripts, shell history, command arguments, ZIP files, or source control.
|
||||
|
||||
## Options and behavior
|
||||
|
||||
- Default model: `gpt-image-2`; use `-Model` or `--model` to override it.
|
||||
- Use low quality for drafts and high quality for final images.
|
||||
- Accept `b64_json` and absolute HTTP(S) URL responses; URL downloads are capped at 50 MB and checked for image signatures.
|
||||
- Use `CODEX_IMAGEGEN_VENV` to choose the private virtual environment location.
|
||||
- Use `CODEX_IMAGEGEN_PYTHON` to choose the Python executable used to bootstrap that environment.
|
||||
- Report the concrete failure category without exposing credentials: missing runtime/dependency, keyring or authentication, network/permission, unsupported model/endpoint, quota, response parsing, download, or output writing.
|
||||
|
||||
## Install a shared package
|
||||
|
||||
Extract the package and run `scripts/install-skill.ps1` on Windows or `scripts/install-skill.sh` on macOS/Linux. Add `-Force` or `--force` only when updating an existing installation; the installer never overwrites the destination's keyring data or any local secret file. Start a new Codex task after installation.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Image Gen Custom"
|
||||
short_description: "Cross-platform custom image generation."
|
||||
default_prompt: "Use $image-gen-custom to generate an image and save it to an explicit local file."
|
||||
@@ -0,0 +1,25 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
Add-Type -AssemblyName System.Security
|
||||
|
||||
$documents = if ($env:CODEX_DOCUMENTS) { $env:CODEX_DOCUMENTS } else { Join-Path $env:USERPROFILE "Documents" }
|
||||
$configDir = Join-Path $documents "Codex\.shared\image-gen-custom"
|
||||
New-Item -ItemType Directory -Force -Path $configDir | Out-Null
|
||||
|
||||
$baseUrl = Read-Host "OpenAI-compatible API base URL"
|
||||
if ([string]::IsNullOrWhiteSpace($baseUrl)) { throw "API base URL cannot be empty." }
|
||||
|
||||
[IO.File]::WriteAllText((Join-Path $configDir "base_url.txt"), $baseUrl.Trim().TrimEnd("/"), [Text.UTF8Encoding]::new($false))
|
||||
Write-Host "API base URL stored in the local configuration."
|
||||
|
||||
$apiKey = Read-Host "OpenAI-compatible API key"
|
||||
if ([string]::IsNullOrWhiteSpace($apiKey)) { throw "API key cannot be empty." }
|
||||
|
||||
$plain = [Text.Encoding]::UTF8.GetBytes($apiKey.Trim())
|
||||
$protected = [System.Security.Cryptography.ProtectedData]::Protect(
|
||||
$plain,
|
||||
$null,
|
||||
[System.Security.Cryptography.DataProtectionScope]::LocalMachine
|
||||
)
|
||||
[Convert]::ToBase64String($protected) | Set-Content -LiteralPath (Join-Path $configDir "api_key.dpapi") -Encoding ascii
|
||||
|
||||
Write-Host "API key stored in a Windows DPAPI-protected local file."
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
python_cmd="${CODEX_IMAGEGEN_PYTHON:-}"
|
||||
if [[ -z "$python_cmd" ]]; then
|
||||
embedded_python="$HOME/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/python.exe"
|
||||
if [[ -x "$embedded_python" ]]; then
|
||||
python_cmd="$embedded_python"
|
||||
else
|
||||
python_cmd="$(command -v python3 || command -v python || true)"
|
||||
fi
|
||||
fi
|
||||
if [[ -z "$python_cmd" ]]; then
|
||||
printf '%s\n' "Python 3 not found; install Python or set CODEX_IMAGEGEN_PYTHON." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
runtime="$($python_cmd "$script_dir/runtime.py" --keyring)"
|
||||
read -r -p "OpenAI-compatible API base URL: " base_url
|
||||
if [[ -z "${base_url// }" ]]; then
|
||||
printf '%s\n' "API base URL cannot be empty." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
"$runtime" "$script_dir/configure_key.py" --base-url "$base_url"
|
||||
printf '%s\n' "Now enter the API key."
|
||||
"$runtime" "$script_dir/configure_key.py"
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Store or remove the API key using a protected local store."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
SERVICE = "codex-image-gen-custom"
|
||||
|
||||
|
||||
def config_dir() -> Path:
|
||||
documents = os.environ.get("CODEX_DOCUMENTS") or str(Path.home() / "Documents")
|
||||
return Path(documents) / "Codex" / ".shared" / "image-gen-custom"
|
||||
|
||||
|
||||
def base_url_path() -> Path:
|
||||
return config_dir() / "base_url.txt"
|
||||
|
||||
|
||||
def api_key_path() -> Path:
|
||||
return config_dir() / "api_key.xml"
|
||||
|
||||
|
||||
def store_api_key(key: str, user: str) -> None:
|
||||
if os.name == "nt":
|
||||
raise RuntimeError("Use configure.ps1 on Windows to store the API key securely.")
|
||||
keyring = secure_keyring()
|
||||
keyring.set_password(SERVICE, user, key)
|
||||
|
||||
|
||||
def delete_api_key(user: str) -> None:
|
||||
if os.name == "nt":
|
||||
api_key_path().unlink(missing_ok=True)
|
||||
return
|
||||
keyring = secure_keyring()
|
||||
keyring.delete_password(SERVICE, user)
|
||||
|
||||
|
||||
def load_saved_api_key(user: str) -> str:
|
||||
if os.name == "nt":
|
||||
raise RuntimeError("Use generate-image.ps1 on Windows to read the saved API key securely.")
|
||||
keyring = secure_keyring()
|
||||
return (keyring.get_password(SERVICE, user) or "").strip()
|
||||
|
||||
|
||||
def secure_keyring():
|
||||
import keyring
|
||||
|
||||
backend = keyring.get_keyring()
|
||||
backend_id = f"{backend.__class__.__module__}.{backend.__class__.__name__}".lower()
|
||||
allowed = ("windows", "macos", "secretservice", "libsecret", "kwallet")
|
||||
if not any(marker in backend_id for marker in allowed):
|
||||
raise RuntimeError("The configured keyring is not a recognized protected OS credential store")
|
||||
return keyring
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--user", default=os.environ.get("IMAGE_GEN_KEYRING_USER", "default"))
|
||||
parser.add_argument("--delete", action="store_true")
|
||||
parser.add_argument("--base-url", default="")
|
||||
parser.add_argument("--delete-base-url", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.delete_base_url:
|
||||
base_url_path().unlink(missing_ok=True)
|
||||
print("API base URL removed from the local configuration.")
|
||||
return 0
|
||||
if args.base_url:
|
||||
path = base_url_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(args.base_url.rstrip("/") + "\n", encoding="utf-8")
|
||||
print("API base URL stored in the local configuration.")
|
||||
return 0
|
||||
try:
|
||||
if args.delete:
|
||||
delete_api_key(args.user)
|
||||
print("API key removed from the local secret store.")
|
||||
else:
|
||||
key = input("OpenAI-compatible API key: ").strip()
|
||||
if not key:
|
||||
raise ValueError("API key cannot be empty")
|
||||
store_api_key(key, args.user)
|
||||
if load_saved_api_key(args.user) != key:
|
||||
raise RuntimeError("Stored key could not be read back")
|
||||
print("API key stored in the local secret store.")
|
||||
except Exception as exc:
|
||||
print(f"Error: {exc}. Set OPENAI_API_KEY for a one-off run or run configure again.", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,36 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string] $Prompt,
|
||||
[string] $Out = "outputs\imagegen.png",
|
||||
[string] $Size = "1024x1024",
|
||||
[ValidateSet("low", "medium", "high", "auto")][string] $Quality = "low",
|
||||
[string] $Model = "gpt-image-2",
|
||||
[string] $BaseUrl = "",
|
||||
[switch] $Force
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$pythonSelector = Join-Path $scriptDir "runtime.py"
|
||||
$generator = Join-Path $scriptDir "generate_image.py"
|
||||
function Get-ImageGenPython {
|
||||
if ($env:CODEX_IMAGEGEN_PYTHON) {
|
||||
$command = Get-Command $env:CODEX_IMAGEGEN_PYTHON -ErrorAction SilentlyContinue
|
||||
if ($command) { return $command.Source }
|
||||
}
|
||||
$embedded = Join-Path $env:USERPROFILE ".cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe"
|
||||
if (Test-Path -LiteralPath $embedded) { return $embedded }
|
||||
$command = Get-Command python.exe -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($command) { return $command.Source }
|
||||
$command = Get-Command py.exe -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($command) { return $command.Source }
|
||||
throw "Python 3 not found; install Python or set CODEX_IMAGEGEN_PYTHON."
|
||||
}
|
||||
|
||||
$python = Get-ImageGenPython
|
||||
$runtime = & $python $pythonSelector
|
||||
if ($LASTEXITCODE -ne 0 -or -not $runtime) { throw "Could not bootstrap Python runtime." }
|
||||
$args = @($generator, "--model", $Model, "--prompt", $Prompt, "--size", $Size, "--quality", $Quality, "--out", $Out)
|
||||
if ($BaseUrl) { $args += @("--base-url", $BaseUrl) }
|
||||
if ($Force) { $args += "--force" }
|
||||
& $runtime @args
|
||||
if ($LASTEXITCODE -ne 0) { throw "Image generation failed with exit code $LASTEXITCODE." }
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
python_cmd="${CODEX_IMAGEGEN_PYTHON:-}"
|
||||
if [[ -z "$python_cmd" ]]; then
|
||||
embedded_python="$HOME/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/python.exe"
|
||||
if [[ -x "$embedded_python" ]]; then
|
||||
python_cmd="$embedded_python"
|
||||
else
|
||||
python_cmd="$(command -v python3 || command -v python || true)"
|
||||
fi
|
||||
fi
|
||||
if [[ -z "$python_cmd" ]]; then
|
||||
printf '%s\n' "Python 3 not found; install Python or set CODEX_IMAGEGEN_PYTHON." >&2
|
||||
exit 1
|
||||
fi
|
||||
runtime="$($python_cmd "$script_dir/runtime.py" --keyring)"
|
||||
exec "$runtime" "$script_dir/generate_image.py" "$@"
|
||||
@@ -0,0 +1,258 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate an image through an OpenAI-compatible Images API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from typing import Any, Callable
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urljoin
|
||||
from urllib.parse import urlsplit
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
MAX_IMAGE_BYTES = 50 * 1024 * 1024
|
||||
DOWNLOAD_TIMEOUT_SECONDS = 120
|
||||
API_TIMEOUT_SECONDS = 180
|
||||
KEYRING_SERVICE = "codex-image-gen-custom"
|
||||
KEYRING_USER = "default"
|
||||
|
||||
|
||||
def config_dir() -> Path:
|
||||
documents = os.environ.get("CODEX_DOCUMENTS") or str(Path.home() / "Documents")
|
||||
return Path(documents) / "Codex" / ".shared" / "image-gen-custom"
|
||||
|
||||
|
||||
def _windows_saved_key_path() -> Path:
|
||||
return config_dir() / "api_key.dpapi"
|
||||
|
||||
|
||||
def _decrypt_windows_saved_key(path: Path) -> str:
|
||||
shell = shutil.which("powershell.exe") or shutil.which("pwsh")
|
||||
if not shell:
|
||||
raise RuntimeError("PowerShell is required to read the saved Windows API key")
|
||||
script = r"""
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Add-Type -AssemblyName System.Security
|
||||
$protected = [Convert]::FromBase64String((Get-Content -Raw -LiteralPath $env:CODEX_IMAGEGEN_KEY_PATH).Trim())
|
||||
$plain = [System.Security.Cryptography.ProtectedData]::Unprotect(
|
||||
$protected,
|
||||
$null,
|
||||
[System.Security.Cryptography.DataProtectionScope]::LocalMachine
|
||||
)
|
||||
[Text.Encoding]::UTF8.GetString($plain)
|
||||
"""
|
||||
env = os.environ.copy()
|
||||
env["CODEX_IMAGEGEN_KEY_PATH"] = str(path)
|
||||
command = [shell, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script]
|
||||
result = subprocess.run(command, check=False, capture_output=True, text=True, env=env)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError((result.stderr or "PowerShell could not decrypt the saved API key").strip())
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _secure_keyring():
|
||||
try:
|
||||
import keyring
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("OPENAI_API_KEY is not set and the keyring package is unavailable") from exc
|
||||
backend = keyring.get_keyring()
|
||||
backend_id = f"{backend.__class__.__module__}.{backend.__class__.__name__}".lower()
|
||||
allowed = ("windows", "macos", "secretservice", "libsecret", "kwallet")
|
||||
if not any(marker in backend_id for marker in allowed):
|
||||
raise RuntimeError("The configured keyring is not a recognized protected OS credential store")
|
||||
return keyring
|
||||
|
||||
|
||||
def _field(item: Any, name: str) -> Any:
|
||||
if isinstance(item, dict):
|
||||
return item.get(name)
|
||||
return getattr(item, name, None)
|
||||
|
||||
|
||||
def _validate_image(raw: bytes) -> None:
|
||||
signatures = (
|
||||
raw.startswith(b"\x89PNG\r\n\x1a\n"),
|
||||
raw.startswith(b"\xff\xd8\xff"),
|
||||
raw.startswith((b"GIF87a", b"GIF89a")),
|
||||
len(raw) >= 12 and raw[:4] in (b"RIFF", b"RIFX") and raw[8:12] == b"WEBP",
|
||||
)
|
||||
if not raw or not any(signatures):
|
||||
raise ValueError("Image API returned data without a supported image signature")
|
||||
|
||||
|
||||
def _download_image(url: str, *, opener: Callable[..., Any] = urlopen) -> bytes:
|
||||
parsed = urlsplit(url)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
||||
raise ValueError("Image API returned an invalid image URL")
|
||||
request = Request(url, headers={"User-Agent": "codex-image-gen-custom/1"})
|
||||
with opener(request, timeout=DOWNLOAD_TIMEOUT_SECONDS) as response:
|
||||
content_length = response.headers.get("Content-Length")
|
||||
if content_length and int(content_length) > MAX_IMAGE_BYTES:
|
||||
raise ValueError(f"Image URL exceeds the {MAX_IMAGE_BYTES}-byte limit")
|
||||
raw = response.read(MAX_IMAGE_BYTES + 1)
|
||||
if len(raw) > MAX_IMAGE_BYTES:
|
||||
raise ValueError(f"Downloaded image exceeds the {MAX_IMAGE_BYTES}-byte limit")
|
||||
return raw
|
||||
|
||||
|
||||
def extract_image_bytes(item: Any, *, opener: Callable[..., Any] = urlopen) -> bytes:
|
||||
image_b64 = _field(item, "b64_json")
|
||||
if image_b64:
|
||||
try:
|
||||
raw = base64.b64decode(image_b64, validate=True)
|
||||
except (binascii.Error, TypeError, ValueError) as exc:
|
||||
raise ValueError("Image API returned invalid b64_json") from exc
|
||||
else:
|
||||
image_url = _field(item, "url")
|
||||
if not image_url:
|
||||
raise ValueError("Image API response contained neither b64_json nor url")
|
||||
raw = _download_image(str(image_url), opener=opener)
|
||||
_validate_image(raw)
|
||||
return raw
|
||||
|
||||
|
||||
def write_image(raw: bytes, output: Path, *, force: bool) -> None:
|
||||
if output.exists() and not force:
|
||||
raise FileExistsError(f"Output already exists: {output} (use --force to overwrite)")
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, temporary_name = tempfile.mkstemp(prefix=f".{output.name}.", suffix=".tmp", dir=output.parent)
|
||||
temporary = Path(temporary_name)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as stream:
|
||||
stream.write(raw)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
temporary.replace(output)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def load_api_key() -> str:
|
||||
environment_key = os.environ.get("OPENAI_API_KEY", "").strip()
|
||||
if environment_key:
|
||||
return environment_key
|
||||
if os.name == "nt":
|
||||
key_path = _windows_saved_key_path()
|
||||
if not key_path.exists():
|
||||
raise RuntimeError("No API key found. Run configure or set OPENAI_API_KEY")
|
||||
key = _decrypt_windows_saved_key(key_path)
|
||||
if not key:
|
||||
raise RuntimeError("No API key found. Run configure or set OPENAI_API_KEY")
|
||||
return key
|
||||
try:
|
||||
keyring = _secure_keyring()
|
||||
key = keyring.get_password(KEYRING_SERVICE, KEYRING_USER)
|
||||
except Exception as exc:
|
||||
raise RuntimeError("The OS keyring is unavailable; set OPENAI_API_KEY for this run") from exc
|
||||
if not key:
|
||||
raise RuntimeError("No API key found. Run configure or set OPENAI_API_KEY")
|
||||
return key
|
||||
|
||||
|
||||
def _configured_base_url() -> str:
|
||||
environment_url = os.environ.get("OPENAI_BASE_URL", "").strip()
|
||||
if environment_url:
|
||||
return environment_url
|
||||
config_path = config_dir() / "base_url.txt"
|
||||
try:
|
||||
configured_url = config_path.read_text(encoding="utf-8-sig").strip()
|
||||
except OSError:
|
||||
configured_url = ""
|
||||
return configured_url
|
||||
|
||||
|
||||
def _base_url(override: str = "") -> str:
|
||||
base_url = override.strip() or _configured_base_url() or "https://api.openai.com/v1"
|
||||
base_url = base_url.rstrip("/")
|
||||
if not base_url.lower().endswith("/v1"):
|
||||
base_url += "/v1"
|
||||
return base_url
|
||||
|
||||
|
||||
def _request_generation(*, api_key: str, base_url: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
endpoint = urljoin(f"{base_url.rstrip('/')}/", "images/generations")
|
||||
request = Request(
|
||||
endpoint,
|
||||
data=json.dumps(payload).encode("utf-8"),
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "codex-image-gen-custom/1",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urlopen(request, timeout=API_TIMEOUT_SECONDS) as response:
|
||||
raw = response.read()
|
||||
except HTTPError as exc:
|
||||
detail = exc.read(4096).decode("utf-8", errors="replace").strip()
|
||||
suffix = f": {detail}" if detail else ""
|
||||
raise RuntimeError(f"Image API returned HTTP {exc.code}{suffix}") from exc
|
||||
except URLError as exc:
|
||||
raise RuntimeError(f"Could not reach the Image API: {exc.reason}") from exc
|
||||
try:
|
||||
result = json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise ValueError("Image API returned invalid JSON") from exc
|
||||
if not isinstance(result, dict):
|
||||
raise ValueError("Image API returned a non-object JSON response")
|
||||
return result
|
||||
|
||||
|
||||
def generate(args: argparse.Namespace) -> None:
|
||||
api_key = load_api_key()
|
||||
base_url = _base_url(args.base_url)
|
||||
started = time.time()
|
||||
print("Calling Image API. This can take up to a couple of minutes.", file=sys.stderr)
|
||||
result = _request_generation(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
payload={
|
||||
"model": args.model,
|
||||
"prompt": args.prompt,
|
||||
"size": args.size,
|
||||
"quality": args.quality,
|
||||
},
|
||||
)
|
||||
print(f"Generation completed in {time.time() - started:.1f}s.", file=sys.stderr)
|
||||
data = result.get("data")
|
||||
if not isinstance(data, list) or not data:
|
||||
raise ValueError("Image API returned an empty data list")
|
||||
write_image(extract_image_bytes(data[0]), Path(args.out), force=args.force)
|
||||
print(f"Wrote {args.out}")
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--base-url", default="", help="OpenAI-compatible API root")
|
||||
parser.add_argument("--model", default="gpt-image-2")
|
||||
parser.add_argument("--prompt", required=True)
|
||||
parser.add_argument("--size", default="1024x1024")
|
||||
parser.add_argument("--quality", choices=("low", "medium", "high", "auto"), default="low")
|
||||
parser.add_argument("--out", required=True)
|
||||
parser.add_argument("--force", action="store_true")
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
generate(build_parser().parse_args())
|
||||
except Exception as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,15 @@
|
||||
param([string] $CodexHome, [switch] $Force)
|
||||
$ErrorActionPreference = "Stop"
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$pythonCommand = if ($env:CODEX_IMAGEGEN_PYTHON) {
|
||||
Get-Command $env:CODEX_IMAGEGEN_PYTHON -ErrorAction SilentlyContinue
|
||||
} else {
|
||||
Get-Command python.exe -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
}
|
||||
if (-not $pythonCommand) { $pythonCommand = Get-Command py.exe -ErrorAction SilentlyContinue | Select-Object -First 1 }
|
||||
if (-not $pythonCommand) { throw "Python 3 not found; install Python or set CODEX_IMAGEGEN_PYTHON." }
|
||||
$args = @((Join-Path $scriptDir "install_skill.py"))
|
||||
if ($CodexHome) { $args += @("--codex-home", $CodexHome) }
|
||||
if ($Force) { $args += "--force" }
|
||||
& $pythonCommand.Source @args
|
||||
if ($LASTEXITCODE -ne 0) { throw "Skill installation failed with exit code $LASTEXITCODE." }
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
python_cmd="${CODEX_IMAGEGEN_PYTHON:-}"
|
||||
if [[ -z "$python_cmd" ]]; then
|
||||
python_cmd="$(command -v python3 || command -v python || true)"
|
||||
fi
|
||||
if [[ -z "$python_cmd" ]]; then
|
||||
printf '%s\n' "Python 3 not found; install Python or set CODEX_IMAGEGEN_PYTHON." >&2
|
||||
exit 1
|
||||
fi
|
||||
exec "$python_cmd" "$script_dir/install_skill.py" "$@"
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Install this skill without copying local secrets or runtime caches."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
|
||||
EXCLUDED_FILES = {"connection.local.json"}
|
||||
EXCLUDED_DIRS = {".git", "__pycache__", "venv"}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--codex-home", type=Path)
|
||||
parser.add_argument("--force", action="store_true")
|
||||
args = parser.parse_args()
|
||||
source = Path(__file__).resolve().parent.parent
|
||||
codex_home = args.codex_home or Path(os.environ.get("CODEX_HOME", Path.home() / ".codex"))
|
||||
destination = codex_home.expanduser() / "skills" / "image-gen-custom"
|
||||
if source == destination.resolve():
|
||||
print(f"Skill is already installed at: {destination}")
|
||||
return 0
|
||||
if destination.exists() and not args.force:
|
||||
raise SystemExit(f"Skill already exists at {destination}; use --force to update it")
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
for item in source.rglob("*"):
|
||||
relative = item.relative_to(source)
|
||||
if item.name in EXCLUDED_FILES or any(part in EXCLUDED_DIRS for part in relative.parts):
|
||||
continue
|
||||
target = destination / relative
|
||||
if item.is_dir():
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
else:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(item, target)
|
||||
print(f"Installed image-gen-custom to: {destination}")
|
||||
print(f"Configure the API with: python {destination / 'scripts' / 'configure_key.py'}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create the private runtime used by the platform wrappers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import venv
|
||||
|
||||
|
||||
def default_venv() -> Path:
|
||||
override = os.environ.get("CODEX_IMAGEGEN_VENV", "").strip()
|
||||
if override:
|
||||
return Path(override).expanduser()
|
||||
if os.name == "nt":
|
||||
documents = os.environ.get("CODEX_DOCUMENTS") or (Path.home() / "Documents")
|
||||
return Path(documents) / "Codex" / ".shared" / "image-gen-custom" / "venv"
|
||||
if sys.platform == "darwin":
|
||||
return Path.home() / "Library" / "Caches" / "Codex" / "image-gen-custom" / "venv"
|
||||
root = os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache")
|
||||
return Path(root) / "codex" / "image-gen-custom" / "venv"
|
||||
|
||||
|
||||
def bootstrap_python() -> list[str]:
|
||||
override = os.environ.get("CODEX_IMAGEGEN_PYTHON", "").strip()
|
||||
if override:
|
||||
return [override]
|
||||
candidates: list[list[str]] = []
|
||||
if os.name == "nt":
|
||||
cache_root = Path.home() / ".cache" / "codex-runtimes"
|
||||
for path in sorted(cache_root.glob("**/dependencies/python/python.exe")):
|
||||
if path.is_file():
|
||||
candidates.append([str(path)])
|
||||
for candidate in ("py", "python", "python3"):
|
||||
found = shutil.which(candidate)
|
||||
if found:
|
||||
candidates.append([found, "-3"] if candidate == "py" else [found])
|
||||
else:
|
||||
for candidate in ("python3", "python"):
|
||||
found = shutil.which(candidate)
|
||||
if found:
|
||||
candidates.append([found])
|
||||
probe = "import sys"
|
||||
for candidate in candidates:
|
||||
try:
|
||||
result = subprocess.run([*candidate, "-c", probe], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
except OSError:
|
||||
continue
|
||||
if result.returncode == 0:
|
||||
return candidate
|
||||
raise RuntimeError("Python 3 was not found; install Python or set CODEX_IMAGEGEN_PYTHON")
|
||||
|
||||
|
||||
def ensure_runtime(require_keyring: bool = False) -> Path:
|
||||
target = default_venv()
|
||||
python_path = target / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
|
||||
if not python_path.exists():
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
command = bootstrap_python() + ["-m", "venv", str(target)]
|
||||
subprocess.run(command, check=True)
|
||||
if require_keyring:
|
||||
check = subprocess.run([str(python_path), "-c", "import keyring"], check=False)
|
||||
else:
|
||||
check = None
|
||||
if check is not None and check.returncode:
|
||||
packages = ["keyring"]
|
||||
subprocess.run(
|
||||
[str(python_path), "-m", "pip", "install", "--disable-pip-version-check", *packages],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
)
|
||||
return python_path
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--keyring", action="store_true")
|
||||
options = parser.parse_args()
|
||||
try:
|
||||
print(ensure_runtime(options.keyring))
|
||||
except Exception as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
Reference in New Issue
Block a user