Files
Mani Saint-Victor 2b28c46caf fix(llm): make gpt-5.x usable through ccproxy Codex OAuth (#324)
* 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>
2026-07-13 11:04:47 +00:00

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