Files
EvoScientist-Multi/EvoScientist/ccproxy_manager.py
T

286 lines
8.9 KiB
Python

"""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 typing import TYPE_CHECKING
if TYPE_CHECKING:
pass
logger = logging.getLogger(__name__)
_DEFAULT_PORT = 8000
# =============================================================================
# Availability & auth checks
# =============================================================================
def is_ccproxy_available() -> bool:
"""Check whether the ``ccproxy`` CLI binary is on PATH."""
return shutil.which("ccproxy") is not None
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:
result = subprocess.run(
["ccproxy", "auth", "status", provider],
capture_output=True,
text=True,
timeout=10,
)
# ccproxy auth status exits 0 when authed
if result.returncode == 0:
summary = _summarize_auth_output(result.stdout)
return True, summary or "Authenticated"
# On failure, include stderr for diagnostics
raw = (result.stdout + result.stderr).strip()
# Strip ANSI escapes for cleaner error messages
import re as _re
clean = _re.sub(r"\x1b\[[0-9;]*m", "", raw)
return False, clean or "Not 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 = _DEFAULT_PORT) -> bool:
"""Check if ccproxy is already serving on the given port."""
import httpx
try:
resp = httpx.get(f"http://127.0.0.1:{port}/", timeout=2.0)
return resp.status_code < 500
except (httpx.ConnectError, httpx.TimeoutException, OSError):
return False
def start_ccproxy(port: int = _DEFAULT_PORT) -> 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 10 seconds.
FileNotFoundError: If ccproxy binary is not found.
"""
proc = subprocess.Popen(
["ccproxy", "serve", "--port", str(port)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
# Wait for health
deadline = time.monotonic() + 10
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 10 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 = _DEFAULT_PORT) -> 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 = _DEFAULT_PORT) -> 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 = _DEFAULT_PORT) -> 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: object) -> 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. "
"Install it with: pip install 'evoscientist[oauth]'"
)
# 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"
)
# Start ccproxy (single process serves both providers)
proc = ensure_ccproxy()
# Set environment for each OAuth provider
if anthropic_oauth:
setup_ccproxy_env()
if openai_oauth:
setup_codex_env()
if proc:
logger.info("Started ccproxy on port %d", _DEFAULT_PORT)
else:
logger.info("Reusing existing ccproxy on port %d", _DEFAULT_PORT)
return proc