"""ccproxy lifecycle management for OAuth-based Anthropic access. Provides functions to start/stop/health-check ccproxy, which allows users with a Claude Pro/Max subscription to use EvoScientist without a separate API key by reusing Claude Code's OAuth tokens. ccproxy is invoked via subprocess (not Python imports) so the ``ccproxy-api`` package is truly optional at runtime. """ from __future__ import annotations import logging import os import shutil import subprocess import time from EvoScientist.config import EvoScientistConfig logger = logging.getLogger(__name__) _CCPROXY_AUTH_TIMEOUT_SECONDS = 30 _CCPROXY_HEALTH_TIMEOUT_SECONDS = 180 # ============================================================================= # Availability & auth checks # ============================================================================= def _ccproxy_exe() -> str | None: """Return the path to the ccproxy binary, or None if not found. Checks PATH first, then the current Python environment's bin directory (handles conda envs where newly installed binaries may not be visible to shutil.which immediately after pip install). """ found = shutil.which("ccproxy") if found: return found import sys as _sys candidate = os.path.join(os.path.dirname(_sys.executable), "ccproxy") if os.path.isfile(candidate) and os.access(candidate, os.X_OK): return candidate return None def is_ccproxy_available() -> bool: """Check whether the ``ccproxy`` CLI binary is available.""" return _ccproxy_exe() is not None def _is_editable_install() -> bool: """Return True if EvoScientist was installed in editable/development mode. Checks all matching distributions because a stale ``.egg-info`` in the project root can shadow the real ``dist-info`` in site-packages. """ try: import importlib.metadata as _meta import json for dist in _meta.distributions(): name = dist.metadata.get("Name", "") if name.lower() != "evoscientist": continue direct_url = dist.read_text("direct_url.json") if direct_url is not None: data = json.loads(direct_url) if data.get("dir_info", {}).get("editable", False) is True: return True except Exception: pass return False def _oauth_install_hint() -> str: """Return the appropriate install command depending on install method.""" if _is_editable_install(): return "uv sync --extra oauth or pip install -e '.[oauth]'" return "pip install 'evoscientist[oauth]'" def _summarize_auth_output(raw: str) -> str: """Extract key fields from ccproxy auth status output into a one-line summary. Parses the Rich table output for Email, Subscription, and Status fields. Returns e.g. ``"user@example.com (plus, active)"``. Falls back to ``"Authenticated"`` if parsing fails. """ import re as _re # Strip ANSI escape sequences clean = _re.sub(r"\x1b\[[0-9;]*m", "", raw) # Parse "Key<2+ spaces>Value" table rows, match exact key names fields: dict[str, str] = {} for line in clean.splitlines(): m = _re.match(r"\s*(.+?)\s{2,}(.+)", line) if not m: continue key, val = m.group(1).strip(), m.group(2).strip() if key in ("Email", "Subscription", "Subscription Status"): fields[key.lower().replace(" ", "_")] = val email = fields.get("email", "") sub = fields.get("subscription", "") status = fields.get("subscription_status", "") if email: detail = ", ".join(filter(None, [sub, status])) return f"{email} ({detail})" if detail else email return "Authenticated" def check_ccproxy_auth(provider: str = "claude_api") -> tuple[bool, str]: """Check if ccproxy has valid OAuth credentials. Args: provider: ccproxy provider name ("claude_api" or "codex"). Returns: (is_valid, message) tuple. """ try: exe = _ccproxy_exe() or "ccproxy" result = subprocess.run( [exe, "auth", "status", provider], capture_output=True, text=True, # ccproxy's CLI initializes its full plugin system on every # invocation — a cold start takes ~10s on Apple Silicon, so a # 10s timeout made OAuth startup fail intermittently with # "Auth check timed out". timeout=_CCPROXY_AUTH_TIMEOUT_SECONDS, ) import re as _re raw = (result.stdout + result.stderr).strip() clean = _re.sub(r"\x1b\[[0-9;]*m", "", raw) # Filter out structlog warning/noise lines, keep only status lines status_lines = [ line for line in clean.splitlines() if line.strip() and not _re.match(r"\d{4}-\d{2}-\d{2}", line.strip()) and "warning" not in line.lower() and "plugin" not in line.lower() ] status_msg = " ".join(status_lines).strip() # ccproxy auth status may exit 0 even when not authenticated — # detect failure by checking output content if result.returncode != 0 or "not authenticated" in clean.lower(): return False, status_msg or "Not authenticated" summary = _summarize_auth_output(result.stdout) return True, summary or "Authenticated" except FileNotFoundError: return False, "ccproxy not found" except subprocess.TimeoutExpired: return False, "Auth check timed out" except Exception as exc: return False, f"Auth check failed: {exc}" # ============================================================================= # Process management # ============================================================================= def is_ccproxy_running(port: int) -> bool: """Check if ccproxy is already serving on the given port.""" import httpx try: resp = httpx.get(f"http://127.0.0.1:{port}/health/live", timeout=2.0) return resp.status_code == 200 except (httpx.ConnectError, httpx.TimeoutException, OSError): return False def write_ccproxy_config() -> str: """Write the ccproxy config file EvoScientist passes to ``serve --config``. Disables ccproxy's default Codex model mappings, which rewrite any ``gpt-*``/``o1-*``/``o3-*``/``claude-*`` model to ``gpt-5.3-codex`` before forwarding — silently overriding the model the user configured (and failing outright on accounts where ``gpt-5.3-codex`` is not served). With no mappings, the requested model reaches the Codex backend unmodified. Returns: Absolute path to the generated config file. """ from EvoScientist.config import get_config_dir path = get_config_dir() / "ccproxy.toml" path.parent.mkdir(parents=True, exist_ok=True) path.write_text( "# Generated by EvoScientist (ccproxy_manager) — do not edit;\n" "# regenerated on every ccproxy start.\n" "[plugins.codex]\n" "model_mappings = []\n", encoding="utf-8", ) return str(path) def start_ccproxy(port: int) -> subprocess.Popen: """Start ccproxy serve as a background process. Args: port: Port number for the proxy server. Returns: The Popen handle for the ccproxy process. Raises: RuntimeError: If ccproxy fails to become healthy within ``_CCPROXY_HEALTH_TIMEOUT_SECONDS``. FileNotFoundError: If ccproxy binary is not found. """ exe = _ccproxy_exe() or "ccproxy" cmd = [exe, "serve", "--port", str(port)] try: cmd += ["--config", write_ccproxy_config()] except (OSError, UnicodeError) as exc: logger.warning( "Could not write ccproxy config (%s); starting with defaults — " "Codex model mappings will rewrite gpt-* models to gpt-5.3-codex", exc, ) logger.warning( "Starting ccproxy on port %d; first startup may take up to %d seconds", port, _CCPROXY_HEALTH_TIMEOUT_SECONDS, ) proc = subprocess.Popen( cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) deadline = time.monotonic() + _CCPROXY_HEALTH_TIMEOUT_SECONDS while time.monotonic() < deadline: if proc.poll() is not None: raise RuntimeError( f"ccproxy exited immediately with code {proc.returncode}" ) if is_ccproxy_running(port): return proc time.sleep(0.3) # Timed out — clean up proc.terminate() try: proc.wait(timeout=3) except subprocess.TimeoutExpired: proc.kill() raise RuntimeError( "ccproxy did not become healthy within " f"{_CCPROXY_HEALTH_TIMEOUT_SECONDS} seconds" ) def stop_ccproxy(proc: subprocess.Popen | None) -> None: """Gracefully stop a ccproxy process. Safe to call with None (no-op). """ if proc is None: return try: proc.terminate() proc.wait(timeout=5) except subprocess.TimeoutExpired: proc.kill() proc.wait(timeout=2) except Exception: pass def ensure_ccproxy(port: int) -> subprocess.Popen | None: """Ensure ccproxy is running — reuse existing or start new. Returns: Popen handle if we started a new process, None if already running. """ if is_ccproxy_running(port): logger.debug("ccproxy already running on port %d", port) return None return start_ccproxy(port) # ============================================================================= # Environment setup # ============================================================================= def setup_ccproxy_env(port: int) -> None: """Set environment variables for Anthropic ccproxy routing. Force-sets ``ANTHROPIC_BASE_URL`` and ``ANTHROPIC_API_KEY`` so that downstream LangChain/Anthropic clients route through ccproxy. Always overrides existing values — when this function is called, we've decided to use ccproxy, so env must point to it. """ os.environ["ANTHROPIC_BASE_URL"] = f"http://127.0.0.1:{port}/claude" os.environ["ANTHROPIC_API_KEY"] = "ccproxy-oauth" def setup_codex_env(port: int) -> None: """Set environment variables for OpenAI/Codex ccproxy routing. Force-sets ``OPENAI_BASE_URL`` and ``OPENAI_API_KEY`` so that downstream LangChain/OpenAI clients route through ccproxy's Codex endpoint. Always overrides existing values — when this function is called, we've decided to use ccproxy, so env must point to it. """ os.environ["OPENAI_BASE_URL"] = f"http://127.0.0.1:{port}/codex/v1" os.environ["OPENAI_API_KEY"] = "ccproxy-oauth" # ============================================================================= # High-level orchestration # ============================================================================= def maybe_start_ccproxy(config: EvoScientistConfig) -> subprocess.Popen | None: """High-level: conditionally start ccproxy based on config. Checks ``config.anthropic_auth_mode`` and ``config.openai_auth_mode``: - ``oauth``: ccproxy must work — raises on failure. - ``api_key``: no-op for that provider. When either provider uses OAuth, ccproxy is started (single process serves both providers). Environment variables are set for each provider that uses OAuth. Args: config: An ``EvoScientistConfig`` instance. Returns: Popen handle if we started ccproxy, None otherwise. """ anthropic_oauth = getattr(config, "anthropic_auth_mode", "api_key") == "oauth" openai_oauth = getattr(config, "openai_auth_mode", "api_key") == "oauth" if not anthropic_oauth and not openai_oauth: return None if not is_ccproxy_available(): raise RuntimeError( "ccproxy is required for OAuth mode but not found. " f"Install it with: {_oauth_install_hint()}" ) # Check auth for each provider that uses OAuth if anthropic_oauth: authed, msg = check_ccproxy_auth("claude_api") if not authed: raise RuntimeError( f"ccproxy Anthropic OAuth not authenticated: {msg}\n" "Run: ccproxy auth login claude_api" ) if openai_oauth: authed, msg = check_ccproxy_auth("codex") if not authed: raise RuntimeError( f"ccproxy Codex OAuth not authenticated: {msg}\n" "Run: ccproxy auth login codex" ) port = config.ccproxy_port if not (1 <= port <= 65535): raise ValueError(f"Invalid ccproxy port: {port}. Must be between 1 and 65535.") # Start ccproxy (single process serves both providers) proc = ensure_ccproxy(port) # Set environment for each OAuth provider if anthropic_oauth: setup_ccproxy_env(port) if openai_oauth: setup_codex_env(port) if proc: logger.info("Started ccproxy on port %d", port) else: logger.info("Reusing existing ccproxy on port %d", port) return proc