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", "", "Bearer "} BASE_URL_PLACEHOLDERS = {"", "https://example.com", "https://example.com/v1"} MODEL_PLACEHOLDERS = {""} 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 ""