Files
gpt-image-2-generator/scripts/runtime_config.py
2026-04-27 14:33:50 +08:00

400 lines
13 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
import os
from pathlib import Path
from typing import Callable
import urllib.parse
ROOT_DIR = Path(__file__).resolve().parents[1]
DEFAULT_BASE_URL = "https://apicodex.xyz"
DEFAULT_MODEL = "gpt-image-2"
DEFAULT_OUTPUT_DIR_NAME = "outputs"
DEFAULT_REQUEST_STYLE = "apicodex"
TOKEN_PLACEHOLDERS = {"YOUR_API_TOKEN_HERE", "<token>", "Bearer <token>"}
BASE_URL_PLACEHOLDERS = {"<base_url>", "https://example.com", "https://example.com/v1"}
MODEL_PLACEHOLDERS = {"<model>"}
REQUEST_STYLE_VALUES = {"auto", "apicodex", "openai"}
TOKEN_ENV_VARS = (
"GPT_IMAGE_2_TOKEN",
"APICODEX_API_KEY",
"APICODEX_TOKEN",
"IMAGE_API_KEY",
"CODEX_API_KEY",
"OPENAI_API_KEY",
)
BASE_URL_ENV_VARS = (
"GPT_IMAGE_2_BASE_URL",
"APICODEX_BASE_URL",
"IMAGE_BASE_URL",
"OPENAI_BASE_URL",
"OPENAI_API_BASE",
)
MODEL_ENV_VARS = (
"GPT_IMAGE_2_MODEL",
"IMAGE_MODEL",
)
REQUEST_STYLE_ENV_VARS = (
"GPT_IMAGE_2_REQUEST_STYLE",
"IMAGE_REQUEST_STYLE",
"IMAGE_API_STYLE",
)
OUTPUT_DIR_ENV_VARS = (
"GPT_IMAGE_2_OUTPUT_DIR",
"IMAGE_OUTPUT_DIR",
)
class ConfigError(RuntimeError):
pass
@dataclass(frozen=True)
class ConfigValue:
value: str
source: str
@dataclass(frozen=True)
class RuntimeConfig:
execution_root: Path
env_file: Path
project_config_dir: Path
skill_root: Path
skill_config_dir: Path
token: str | None
token_source: str | None
base_url: str
base_url_source: str
model: str
model_source: str
request_style: str
request_style_source: str
output_dir: Path
output_dir_source: str
def normalize_token(raw_token: str) -> str:
return raw_token.strip().removeprefix("Bearer ").strip()
def normalize_base_url(raw_value: str) -> str:
value = raw_value.strip().strip('"').strip("'").rstrip("/")
if not value:
return ""
parsed = urllib.parse.urlparse(value)
if not parsed.scheme or not parsed.netloc:
return value
path = parsed.path.rstrip("/")
if not path:
path = "/v1"
normalized = parsed._replace(path=path)
return urllib.parse.urlunparse(normalized).rstrip("/")
def normalize_model(raw_value: str) -> str:
return raw_value.strip()
def normalize_request_style(raw_value: str) -> str:
value = raw_value.strip().strip('"').strip("'").lower()
if not value:
return ""
if value not in REQUEST_STYLE_VALUES:
raise ConfigError(
f"Unsupported request style: {raw_value}. Use one of: {', '.join(sorted(REQUEST_STYLE_VALUES))}"
)
return value
def infer_request_style(base_url: str) -> str:
parsed = urllib.parse.urlparse(normalize_base_url(base_url))
host = (parsed.netloc or "").lower()
if "apicodex" in host:
return "apicodex"
if "openai" in host:
return "openai"
return "openai"
def missing_token_message() -> str:
env_list = ", ".join(TOKEN_ENV_VARS)
return (
f"No usable token found. Set one of these environment variables: {env_list}, "
"or configure the current working directory with .env / config/token.txt. "
f"Skill fallback config is {ROOT_DIR / 'config' / 'token.txt'}."
)
def build_api_url(base_url: str, path_fragment: str) -> str:
base = normalize_base_url(base_url).rstrip("/") + "/"
return urllib.parse.urljoin(base, path_fragment.lstrip("/"))
def load_runtime_config(
*,
execution_root: str | Path | None = None,
config_dir: str | Path | None = None,
env_file: str | Path | None = None,
base_url_override: str | None = None,
model_override: str | None = None,
output_dir_override: str | Path | None = None,
require_token: bool = True,
) -> RuntimeConfig:
resolved_execution_root = Path(execution_root or os.getcwd()).expanduser().resolve()
resolved_config_dir = _resolve_project_config_dir(resolved_execution_root, config_dir)
resolved_env_file = _resolve_env_file(resolved_execution_root, env_file)
skill_config_dir = ROOT_DIR / "config"
env_map = parse_env_file(resolved_env_file)
token_value = _select_text_value(
env_vars=TOKEN_ENV_VARS,
env_map=env_map,
file_candidates=(resolved_config_dir / "token.txt", skill_config_dir / "token.txt"),
normalizer=normalize_token,
placeholders=TOKEN_PLACEHOLDERS,
)
if require_token and token_value is None:
raise ConfigError(missing_token_message())
base_url_value = _select_text_value(
env_vars=BASE_URL_ENV_VARS,
env_map=env_map,
file_candidates=(
resolved_config_dir / "base_url.txt",
resolved_config_dir / "baseurl.txt",
skill_config_dir / "base_url.txt",
skill_config_dir / "baseurl.txt",
),
normalizer=normalize_base_url,
placeholders=BASE_URL_PLACEHOLDERS,
override=(base_url_override, "cli:--base-url") if base_url_override else None,
) or ConfigValue(normalize_base_url(DEFAULT_BASE_URL), "default")
model_value = _select_text_value(
env_vars=MODEL_ENV_VARS,
env_map=env_map,
file_candidates=(resolved_config_dir / "model.txt", skill_config_dir / "model.txt"),
normalizer=normalize_model,
placeholders=MODEL_PLACEHOLDERS,
override=(model_override, "cli:--model") if model_override else None,
) or ConfigValue(DEFAULT_MODEL, "default")
request_style_value = _select_text_value(
env_vars=REQUEST_STYLE_ENV_VARS,
env_map=env_map,
file_candidates=(resolved_config_dir / "request_style.txt", skill_config_dir / "request_style.txt"),
normalizer=normalize_request_style,
placeholders=set(),
)
if request_style_value is None or request_style_value.value == "auto":
resolved_request_style = infer_request_style(base_url_value.value)
request_style_source = (
f"{request_style_value.source}->inferred:base-url"
if request_style_value is not None
else "inferred:base-url"
)
else:
resolved_request_style = request_style_value.value
request_style_source = request_style_value.source
output_dir_value = _select_text_value(
env_vars=OUTPUT_DIR_ENV_VARS,
env_map=env_map,
file_candidates=(resolved_config_dir / "output_dir.txt", skill_config_dir / "output_dir.txt"),
normalizer=lambda value: value.strip(),
placeholders=set(),
override=(str(output_dir_override), "cli:--output-dir") if output_dir_override else None,
)
if output_dir_value is None:
resolved_output_dir = resolved_execution_root / DEFAULT_OUTPUT_DIR_NAME
output_dir_source = "default"
else:
resolved_output_dir = resolve_path_from_root(output_dir_value.value, resolved_execution_root)
output_dir_source = output_dir_value.source
return RuntimeConfig(
execution_root=resolved_execution_root,
env_file=resolved_env_file,
project_config_dir=resolved_config_dir,
skill_root=ROOT_DIR,
skill_config_dir=skill_config_dir,
token=token_value.value if token_value else None,
token_source=token_value.source if token_value else None,
base_url=base_url_value.value,
base_url_source=base_url_value.source,
model=model_value.value,
model_source=model_value.source,
request_style=resolved_request_style,
request_style_source=request_style_source,
output_dir=resolved_output_dir,
output_dir_source=output_dir_source,
)
def runtime_config_summary(config: RuntimeConfig) -> dict[str, str | bool | None]:
return {
"execution_root": str(config.execution_root),
"env_file": str(config.env_file),
"env_file_exists": config.env_file.exists(),
"project_config_dir": str(config.project_config_dir),
"skill_config_dir": str(config.skill_config_dir),
"token_configured": bool(config.token),
"token_source": config.token_source,
"base_url": config.base_url,
"base_url_source": config.base_url_source,
"model": config.model,
"model_source": config.model_source,
"request_style": config.request_style,
"request_style_source": config.request_style_source,
"output_dir": str(config.output_dir),
"output_dir_source": config.output_dir_source,
}
def save_token(token: str, *, execution_root: str | Path | None = None, config_dir: str | Path | None = None) -> Path:
normalized = normalize_token(token)
if not normalized or normalized in TOKEN_PLACEHOLDERS:
raise ConfigError("Token cannot be empty")
target_dir = _resolve_project_config_dir(Path(execution_root or os.getcwd()).expanduser().resolve(), config_dir)
target_dir.mkdir(parents=True, exist_ok=True)
target_path = target_dir / "token.txt"
target_path.write_text(
f"# apicodex.xyz API token\n{normalized}\n",
encoding="utf-8",
)
return target_path
def save_base_url(
base_url: str,
*,
execution_root: str | Path | None = None,
config_dir: str | Path | None = None,
) -> Path:
normalized = normalize_base_url(base_url)
if not normalized or normalized in BASE_URL_PLACEHOLDERS:
raise ConfigError("Base URL cannot be empty")
target_dir = _resolve_project_config_dir(Path(execution_root or os.getcwd()).expanduser().resolve(), config_dir)
target_dir.mkdir(parents=True, exist_ok=True)
target_path = target_dir / "base_url.txt"
target_path.write_text(
f"# API base URL. Either host root or full /v1 path is accepted.\n{normalized}\n",
encoding="utf-8",
)
return target_path
def resolve_path_from_root(value: str, root: Path) -> Path:
path = Path(value).expanduser()
if path.is_absolute():
return path
return (root / path).resolve()
def parse_env_file(path: Path) -> dict[str, str]:
if not path.exists():
return {}
values: dict[str, str] = {}
for raw_line in path.read_text(encoding="utf-8-sig").splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if line.startswith("export "):
line = line[7:].lstrip()
if "=" not in line:
continue
key, raw_value = line.split("=", 1)
key = key.strip()
if not key:
continue
values[key] = _parse_env_value(raw_value.strip())
return values
def _parse_env_value(raw_value: str) -> str:
if not raw_value:
return ""
if raw_value[0] in {'"', "'"} and raw_value[-1] == raw_value[0]:
return raw_value[1:-1]
if " #" in raw_value:
raw_value = raw_value.split(" #", 1)[0].rstrip()
return raw_value.strip()
def _resolve_project_config_dir(execution_root: Path, config_dir: str | Path | None) -> Path:
raw_value = config_dir or os.environ.get("GPT_IMAGE_2_CONFIG_DIR")
if not raw_value:
return execution_root / "config"
return resolve_path_from_root(str(raw_value), execution_root)
def _resolve_env_file(execution_root: Path, env_file: str | Path | None) -> Path:
raw_value = env_file or os.environ.get("GPT_IMAGE_2_ENV_FILE")
if not raw_value:
return execution_root / ".env"
return resolve_path_from_root(str(raw_value), execution_root)
def _select_text_value(
*,
env_vars: tuple[str, ...],
env_map: dict[str, str],
file_candidates: tuple[Path, ...],
normalizer: Callable[[str], str],
placeholders: set[str],
override: tuple[str, str] | None = None,
) -> ConfigValue | None:
if override is not None:
resolved = _coerce_text_value(override[0], override[1], normalizer, placeholders)
if resolved is not None:
return resolved
for env_name in env_vars:
resolved = _coerce_text_value(os.environ.get(env_name, ""), f"env:{env_name}", normalizer, placeholders)
if resolved is not None:
return resolved
for env_name in env_vars:
if env_name not in env_map:
continue
resolved = _coerce_text_value(env_map[env_name], f"env-file:{env_name}", normalizer, placeholders)
if resolved is not None:
return resolved
for candidate in file_candidates:
resolved = _coerce_text_value(_read_config_text(candidate), f"file:{candidate}", normalizer, placeholders)
if resolved is not None:
return resolved
return None
def _coerce_text_value(
raw_value: str,
source: str,
normalizer: Callable[[str], str],
placeholders: set[str],
) -> ConfigValue | None:
value = normalizer(raw_value)
if not value or value in placeholders:
return None
return ConfigValue(value=value, source=source)
def _read_config_text(path: Path) -> str:
if not path.exists():
return ""
for line in path.read_text(encoding="utf-8-sig").splitlines():
stripped = line.strip()
if stripped and not stripped.startswith("#"):
return stripped
return ""