400 lines
13 KiB
Python
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 ""
|