2b28c46caf
* fix(llm): make gpt-5.x usable through ccproxy Codex OAuth
Two independent blockers made current OpenAI models fail when routed
through ccproxy's Codex OAuth endpoint:
1. ccproxy's default Codex model mappings rewrite any gpt-*/o1-*/o3-*/
claude-* model to gpt-5.3-codex before forwarding, silently overriding
the configured model and failing outright on accounts where
gpt-5.3-codex is not served ("The 'gpt-5.3-codex' model is not
supported when using Codex with a ChatGPT account").
start_ccproxy() now generates a config with empty codex model
mappings and passes it via 'ccproxy serve --config'.
2. ccproxy forwards the client's own User-Agent upstream and only
gap-fills its Codex headers, so the backend gates current models on
the client identity ("The '<model>' model requires a newer version
of Codex"). get_chat_model() now sends Codex-CLI-shaped
originator/version/User-Agent headers when the ccproxy Codex adapter
is detected, overridable via EVOSCIENTIST_CODEX_CLIENT_VERSION.
Verified live: gpt-5.5 and gpt-5.4 complete successfully through
ccproxy Codex OAuth on a ChatGPT Plus account with both fixes; each
fails without them.
* fix(ccproxy): harden Codex client routing
* fix(llm): keep Codex client identity consistent
* docs: clarify Codex version floor
* style: ruff format models.py after merge
---------
Co-authored-by: Xi Zhang <106144707+X-iZhang@users.noreply.github.com>
Co-authored-by: X-iZhang <zacharyzhang2022@gmail.com>
400 lines
13 KiB
Python
400 lines
13 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 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
|