CoolFace
Apppublic

rain34572/responses-adapter-gateway

sourceHugging Facemitupdated 5mo agoView on Hugging Face
0likes
codex_runner.py337 linesDownload Raw Back to root
1from __future__ import annotations2 3import asyncio4import json5import os6from dataclasses import dataclass7from pathlib import Path8from typing import Any9 10 11class CodexRunnerError(RuntimeError):12    pass13 14 15@dataclass16class CodexRunnerConfig:17    codex_bin: str18    workdir: str19    codex_home: str20    model: str21    model_provider: str22    provider_name: str23    api_base_url: str24    api_key: str25    timeout_seconds: float26 27    @classmethod28    def from_env(29        cls,30        default_model: str,31        default_api_base_url: str,32        default_api_key: str,33        default_timeout_seconds: float,34    ) -> "CodexRunnerConfig":35        return cls(36            codex_bin=os.getenv("CODEX_BIN", "codex").strip() or "codex",37            workdir=os.getenv("CODEX_WORKDIR", "/tmp").strip() or "/tmp",38            codex_home=os.getenv("CODEX_HOME", str(Path.home() / ".codex")).strip() or str(Path.home() / ".codex"),39            model=os.getenv("CODEX_MODEL", default_model).strip() or default_model,40            model_provider=os.getenv("CODEX_MODEL_PROVIDER", "relaygw").strip() or "relaygw",41            provider_name=os.getenv("CODEX_PROVIDER_NAME", "Relay Gateway").strip() or "Relay Gateway",42            api_base_url=os.getenv("CODEX_API_BASE_URL", default_api_base_url).strip().rstrip("/"),43            api_key=os.getenv("CODEX_API_KEY", default_api_key).strip() or default_api_key,44            timeout_seconds=float(os.getenv("CODEX_TIMEOUT_SECONDS", str(default_timeout_seconds))),45        )46 47    @classmethod48    def from_runtime(49        cls,50        model: str,51        api_base_url: str,52        api_key: str,53        timeout_seconds: float,54    ) -> "CodexRunnerConfig":55        return cls(56            codex_bin=os.getenv("CODEX_BIN", "codex").strip() or "codex",57            workdir=os.getenv("CODEX_WORKDIR", "/tmp").strip() or "/tmp",58            codex_home=os.getenv("CODEX_HOME", str(Path.home() / ".codex")).strip() or str(Path.home() / ".codex"),59            model=model,60            model_provider=os.getenv("CODEX_MODEL_PROVIDER", "relaygw").strip() or "relaygw",61            provider_name=os.getenv("CODEX_PROVIDER_NAME", "Relay Gateway").strip() or "Relay Gateway",62            api_base_url=api_base_url.strip().rstrip("/"),63            api_key=api_key.strip(),64            timeout_seconds=timeout_seconds,65        )66 67 68def _escape_toml(value: str) -> str:69    return value.replace("\\", "\\\\").replace('"', '\\"')70 71 72class CodexRunner:73    def __init__(self, config: CodexRunnerConfig):74        self.config = config75        self._session_threads: dict[str, str] = {}76 77    async def run(78        self,79        prompt: str,80        system_prompt: str,81        session_id: str | None,82        previous_response_id: str | None,83        metadata: dict[str, Any],84    ) -> dict[str, Any]:85        self._ensure_provider_files()86        full_prompt = self._build_prompt(87            prompt=prompt,88            system_prompt=system_prompt,89            session_id=session_id,90            previous_response_id=previous_response_id,91            metadata=metadata,92        )93        thread_id = self._resolve_thread_id(session_id, previous_response_id)94        cmd = self._build_command(thread_id)95 96        env = os.environ.copy()97        env["CODEX_HOME"] = self.config.codex_home98        if self.config.api_key:99            env["OPENAI_API_KEY"] = self.config.api_key100 101        process = None102        stdout = b""103        stderr = b""104        try:105            process = await asyncio.create_subprocess_exec(106                *cmd,107                stdin=asyncio.subprocess.PIPE,108                stdout=asyncio.subprocess.PIPE,109                stderr=asyncio.subprocess.PIPE,110                env=env,111                cwd=self.config.workdir,112            )113            stdout, stderr = await asyncio.wait_for(114                process.communicate(input=full_prompt.encode("utf-8")),115                timeout=self.config.timeout_seconds,116            )117        except FileNotFoundError as exc:118            raise CodexRunnerError(f"codex binary not found: {self.config.codex_bin}") from exc119        except asyncio.TimeoutError as exc:120            raise CodexRunnerError("codex exec timed out") from exc121 122        if process is None or process.returncode != 0:123            stderr_text = stderr.decode("utf-8", errors="ignore").strip()124            stdout_text = stdout.decode("utf-8", errors="ignore").strip()125            code = process.returncode if process is not None else "unknown"126            raise CodexRunnerError(stderr_text or stdout_text or f"codex exited with {code}")127 128        parsed = self._parse_exec_jsonl(129            stdout.decode("utf-8", errors="ignore"),130            fallback_thread_id=thread_id,131        )132        reply = parsed["reply"]133        if not reply:134            raise CodexRunnerError("codex returned empty output")135        response_id = parsed["thread_id"]136        if session_id and response_id:137            self._session_threads[session_id] = response_id138 139        return {140            "reply": reply,141            "model": self.config.model,142            "session_id": session_id,143            "response_id": response_id,144            "usage": None,145            "raw": {146                "runtime": "codex_cli",147                "events": parsed["events"],148            },149        }150 151    @staticmethod152    def _build_prompt(153        prompt: str,154        system_prompt: str,155        session_id: str | None,156        previous_response_id: str | None,157        metadata: dict[str, Any],158    ) -> str:159        lines = [160            "You are running as a relay-connected coding agent.",161            "",162            "System instructions:",163            system_prompt,164            "",165        ]166        if session_id:167            lines.append(f"Session ID: {session_id}")168        if previous_response_id:169            lines.append(f"Previous response ID: {previous_response_id}")170        if metadata:171            lines.append(f"Metadata: {metadata}")172        lines.extend(["", "User prompt:", prompt.strip()])173        return "\n".join(lines).strip()174 175    def _resolve_thread_id(176        self,177        session_id: str | None,178        previous_response_id: str | None,179    ) -> str | None:180        if session_id:181            cached = self._session_threads.get(session_id)182            if cached:183                return cached184        if isinstance(previous_response_id, str) and previous_response_id.strip():185            return previous_response_id.strip()186        return None187 188    def _build_command(self, thread_id: str | None) -> list[str]:189        base = [190            self.config.codex_bin,191            "exec",192        ]193        if thread_id:194            base.append("resume")195        base.append("--skip-git-repo-check")196        if not thread_id:197            base.extend([198                "--sandbox",199                "danger-full-access",200            ])201        base.extend([202            "--model",203            self.config.model,204            "-c",205            f'model_provider="{_escape_toml(self.config.model_provider)}"',206        ])207        if thread_id:208            base.extend([thread_id, "--json", "-"])209        else:210            base.extend(["--json", "--cd", self.config.workdir, "-"])211        return base212 213    def _ensure_provider_files(self) -> None:214        home = Path(self.config.codex_home)215        home.mkdir(parents=True, exist_ok=True)216        self._write_provider_config(home)217        self._write_auth_config(home)218 219    def _write_provider_config(self, home: Path) -> None:220        cfg_path = home / "config.toml"221        existing = cfg_path.read_text(encoding="utf-8") if cfg_path.exists() else ""222        section = self._build_provider_section()223        updated = self._upsert_provider_section(existing, section)224        cfg_path.write_text(updated, encoding="utf-8")225 226    def _write_auth_config(self, home: Path) -> None:227        if not self.config.api_key:228            return229        auth_path = home / "auth.json"230        payload = {231            "OPENAI_API_KEY": self.config.api_key,232            "auth_mode": "apikey",233        }234        auth_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")235        os.chmod(auth_path, 0o600)236 237    def _build_provider_section(self) -> str:238        lines = [239            f"[model_providers.{self.config.model_provider}]",240            f'name = "{_escape_toml(self.config.provider_name)}"',241        ]242        if self.config.api_base_url:243            lines.append(f'base_url = "{_escape_toml(self.config.api_base_url)}"')244        lines.extend([245            'env_key = "OPENAI_API_KEY"',246            'wire_api = "responses"',247            "",248        ])249        return "\n".join(lines)250 251    def _upsert_provider_section(self, content: str, section: str) -> str:252        header = f"[model_providers.{self.config.model_provider}]"253        subsection_prefix = f"[model_providers.{self.config.model_provider}."254        if header not in content:255            trimmed = content.rstrip()256            return f"{trimmed}\n\n{section}".strip() + "\n"257 258        lines = content.splitlines()259        kept: list[str] = []260        skipping = False261        inserted = False262        for line in lines:263            stripped = line.strip()264            if stripped == header:265                if not inserted:266                    kept.extend(section.rstrip().splitlines())267                    inserted = True268                skipping = True269                continue270            if skipping and stripped.startswith("[") and stripped != header and not stripped.startswith(subsection_prefix):271                skipping = False272            if not skipping:273                kept.append(line)274        if not inserted:275            kept.extend(["", *section.rstrip().splitlines()])276        return "\n".join(kept).strip() + "\n"277 278    def _parse_exec_jsonl(self, raw_output: str, fallback_thread_id: str | None) -> dict[str, Any]:279        thread_id = fallback_thread_id280        reply_parts: list[str] = []281        events: list[dict[str, Any]] = []282        for line in raw_output.splitlines():283            line = line.strip()284            if not line:285                continue286            try:287                event = json.loads(line)288            except json.JSONDecodeError:289                continue290            if not isinstance(event, dict):291                continue292            events.append(event)293            event_type = event.get("type")294            if event_type == "thread.started" and isinstance(event.get("thread_id"), str):295                thread_id = event["thread_id"].strip() or thread_id296                continue297            if event_type == "turn.failed":298                error = event.get("error")299                if isinstance(error, dict) and isinstance(error.get("message"), str):300                    raise CodexRunnerError(error["message"])301                raise CodexRunnerError("codex turn failed")302            if event_type != "item.completed":303                continue304            item = event.get("item")305            if not isinstance(item, dict):306                continue307            if item.get("type") not in {"agent_message", "message"}:308                continue309            text = self._extract_item_text(item)310            if text:311                reply_parts.append(text)312        return {313            "thread_id": thread_id,314            "reply": "\n".join(reply_parts).strip(),315            "events": events,316        }317 318    def _extract_item_text(self, item: dict[str, Any]) -> str:319        for key in ("output_text", "content", "text"):320            text = self._flatten_text(item.get(key))321            if text:322                return text323        return ""324 325    def _flatten_text(self, value: Any) -> str:326        if isinstance(value, str):327            return value.strip()328        if isinstance(value, list):329            parts = [self._flatten_text(item) for item in value]330            return "\n".join([part for part in parts if part]).strip()331        if isinstance(value, dict):332            for key in ("text", "content", "output_text"):333                text = self._flatten_text(value.get(key))334                if text:335                    return text336        return ""337