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