fix: add config option for ccproxy port number (#52)

* fix: add config option for ccproxy port number

* feat: add user prompt for ccproxy port configuration and validation
fix: update is_ccproxy_running to use health check endpoint
test: enhance tests for ccproxy port handling and validation

* fix: streamline ccproxy installation process using _install_pip_package

* fix: auto-patch ccproxy adapter for correct OAuth beta header

---------

Co-authored-by: Xi Zhang <106144707+X-iZhang@users.noreply.github.com>
Co-authored-by: X-iZhang <zacharyzhang2022@gmail.com>
This commit is contained in:
Wiktor Cupiał
2026-03-17 23:27:20 +01:00
committed by GitHub
parent 97684eab7b
commit d5e50a690e
4 changed files with 255 additions and 81 deletions
+100 -18
View File
@@ -15,15 +15,11 @@ import os
import shutil
import subprocess
import time
from typing import TYPE_CHECKING
if TYPE_CHECKING:
pass
from EvoScientist.config import EvoScientistConfig
logger = logging.getLogger(__name__)
_DEFAULT_PORT = 8000
# =============================================================================
# Availability & auth checks
@@ -138,18 +134,18 @@ def check_ccproxy_auth(provider: str = "claude_api") -> tuple[bool, str]:
# =============================================================================
def is_ccproxy_running(port: int = _DEFAULT_PORT) -> bool:
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}/", timeout=2.0)
return resp.status_code < 500
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 start_ccproxy(port: int = _DEFAULT_PORT) -> subprocess.Popen:
def start_ccproxy(port: int) -> subprocess.Popen:
"""Start ccproxy serve as a background process.
Args:
@@ -206,7 +202,7 @@ def stop_ccproxy(proc: subprocess.Popen | None) -> None:
pass
def ensure_ccproxy(port: int = _DEFAULT_PORT) -> subprocess.Popen | None:
def ensure_ccproxy(port: int) -> subprocess.Popen | None:
"""Ensure ccproxy is running — reuse existing or start new.
Returns:
@@ -223,7 +219,7 @@ def ensure_ccproxy(port: int = _DEFAULT_PORT) -> subprocess.Popen | None:
# =============================================================================
def setup_ccproxy_env(port: int = _DEFAULT_PORT) -> None:
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
@@ -236,7 +232,7 @@ def setup_ccproxy_env(port: int = _DEFAULT_PORT) -> None:
os.environ["ANTHROPIC_API_KEY"] = "ccproxy-oauth"
def setup_codex_env(port: int = _DEFAULT_PORT) -> None:
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
@@ -255,7 +251,86 @@ def setup_codex_env(port: int = _DEFAULT_PORT) -> None:
# =============================================================================
def maybe_start_ccproxy(config: object) -> subprocess.Popen | None:
def _patch_ccproxy_oauth_header() -> None:
"""Auto-patch ccproxy's adapter to send the correct OAuth beta header.
ccproxy 0.2.4 hardcodes ``computer-use-2025-01-24`` as the
``anthropic-beta`` header, which causes two problems:
- Missing ``oauth-2025-04-20`` → 401 from Anthropic
- ``computer-use-2025-01-24`` incompatible with OAuth auth → 400
The ccproxy binary may use a different Python environment than the one
running EvoScientist, so we resolve the adapter path via the ccproxy
binary's shebang line rather than the current Python's import system.
This patch is idempotent and places the header AFTER cli_headers so it
cannot be overridden.
"""
import pathlib
import re
try:
ccproxy_bin = _ccproxy_exe()
if not ccproxy_bin:
return
# Find the Python interpreter used by the ccproxy binary via shebang
shebang = pathlib.Path(ccproxy_bin).read_text().splitlines()[0]
python_exe = shebang.lstrip("#!").strip()
# Ask that Python where ccproxy's adapter lives
result = subprocess.run(
[
python_exe, "-c",
"import inspect, ccproxy.plugins.claude_api.adapter as m; print(inspect.getfile(m))",
],
capture_output=True, text=True, timeout=10,
)
if result.returncode != 0:
return
src_file = pathlib.Path(result.stdout.strip())
if not src_file.exists():
return
text = src_file.read_text()
# Check if already correctly patched (oauth header set after cli_headers)
correct = 'filtered_headers["anthropic-beta"] = "oauth-2025-04-20"'
cli_marker = "cli_headers = self._collect_cli_headers()"
if correct in text:
# Verify it's placed after cli_headers
if text.index(correct) > text.index(cli_marker):
return # Already correctly patched
# Move/replace anthropic-beta assignment to after cli_headers loop,
# and set only oauth-2025-04-20 (computer-use-* is incompatible).
# Step 1: remove any existing filtered_headers["anthropic-beta"] line
patched = re.sub(
r'\s*filtered_headers\["anthropic-beta"\]\s*=\s*"[^"]*"\n',
"\n",
text,
)
# Step 2: insert correct assignment after the cli_headers block
insert_after = 'filtered_headers[lk] = value\n'
replacement = (
'filtered_headers[lk] = value\n\n'
' # oauth-2025-04-20: required for OAuth Bearer token auth (Anthropic 2026-03)\n'
' filtered_headers["anthropic-beta"] = "oauth-2025-04-20"\n'
)
patched = patched.replace(insert_after, replacement, 1)
if patched == text:
return
src_file.write_text(patched)
for pyc in src_file.parent.glob("__pycache__/adapter*.pyc"):
pyc.unlink(missing_ok=True)
logger.info("Auto-patched ccproxy adapter: set anthropic-beta=oauth-2025-04-20")
except Exception as exc:
logger.warning("Could not auto-patch ccproxy adapter: %s", exc)
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``:
@@ -301,17 +376,24 @@ def maybe_start_ccproxy(config: object) -> subprocess.Popen | None:
"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.")
# Auto-patch ccproxy adapter to fix OAuth header compatibility
_patch_ccproxy_oauth_header()
# Start ccproxy (single process serves both providers)
proc = ensure_ccproxy()
proc = ensure_ccproxy(port)
# Set environment for each OAuth provider
if anthropic_oauth:
setup_ccproxy_env()
setup_ccproxy_env(port)
if openai_oauth:
setup_codex_env()
setup_codex_env(port)
if proc:
logger.info("Started ccproxy on port %d", _DEFAULT_PORT)
logger.info("Started ccproxy on port %d", port)
else:
logger.info("Reusing existing ccproxy on port %d", _DEFAULT_PORT)
logger.info("Reusing existing ccproxy on port %d", port)
return proc
+88 -63
View File
@@ -759,6 +759,63 @@ def _prompt_and_validate_api_key(
return new_key if new_key else None
def _prompt_ccproxy_port(config: EvoScientistConfig) -> None:
"""Prompt the user for a ccproxy port and save it to config."""
def valid_port(value: str) -> bool:
if not value: # empty = keep default
return True
try:
return 0 < int(value) < 2**16
except (ValueError, TypeError):
return False
current_port = getattr(config, "ccproxy_port", 8000)
try:
raw = questionary.text(
f"Enter port number for ccproxy to run on (Current: {current_port}, Enter to keep):",
validate=valid_port,
style=WIZARD_STYLE,
qmark=QMARK,
).ask()
ccproxy_port = int(raw) if raw else current_port
except (ValueError, TypeError):
ccproxy_port = current_port
console.print(f" [dim]Using default port: {ccproxy_port}[/dim]")
setattr(config, "ccproxy_port", ccproxy_port)
console.print(
f" [green]✓ ccproxy will run on http://127.0.0.1:{ccproxy_port}[/green]"
)
def _run_ccproxy_login(provider: str, label: str) -> None:
"""Run ccproxy auth login for the given provider and show status."""
from ..ccproxy_manager import _ccproxy_exe, check_ccproxy_auth
console.print(" [dim]Opening browser for authentication...[/dim]")
try:
proc = subprocess.run(
[_ccproxy_exe() or "ccproxy", "auth", "login", provider],
capture_output=True,
text=True,
timeout=120,
)
for line in proc.stdout.splitlines():
if line.strip().startswith("https://"):
console.print(f" [dim]Visit: {line.strip()}[/dim]")
break
authed, msg = check_ccproxy_auth(provider)
if authed:
console.print(f" [green]✓ {label}: {msg}[/green]")
else:
console.print(f" [red]Authentication failed: {msg}[/red]")
except subprocess.TimeoutExpired:
console.print(" [red]Login timed out.[/red]")
except Exception as exc:
console.print(f" [red]Login error: {exc}[/red]")
def _step_anthropic_auth_mode(config: EvoScientistConfig) -> str:
"""Step 2a: Select Anthropic authentication mode (API key vs OAuth).
@@ -768,7 +825,7 @@ def _step_anthropic_auth_mode(config: EvoScientistConfig) -> str:
Returns:
Selected auth mode: "api_key", "oauth", or "auto".
"""
from ..ccproxy_manager import _ccproxy_exe, is_ccproxy_available, check_ccproxy_auth
from ..ccproxy_manager import is_ccproxy_available, check_ccproxy_auth
ccproxy_available = is_ccproxy_available()
@@ -825,11 +882,22 @@ def _step_anthropic_auth_mode(config: EvoScientistConfig) -> str:
)
return "api_key"
if auth_mode == "oauth":
_prompt_ccproxy_port(config)
# If OAuth selected, check auth status and offer login
if auth_mode in ("oauth", "auto"):
authed, msg = check_ccproxy_auth()
if authed:
console.print(f" [green]✓ OAuth: {msg}[/green]")
relogin = questionary.confirm(
"Re-authenticate to refresh credentials?",
default=False,
style=CONFIRM_STYLE,
qmark=QMARK,
).ask()
if relogin:
_run_ccproxy_login("claude_api", "OAuth")
else:
console.print(f" [yellow]OAuth not authenticated: {msg}[/yellow]")
login = questionary.confirm(
@@ -839,28 +907,7 @@ def _step_anthropic_auth_mode(config: EvoScientistConfig) -> str:
qmark=QMARK,
).ask()
if login:
console.print(" [dim]Opening browser for authentication...[/dim]")
try:
proc = subprocess.run(
[_ccproxy_exe() or "ccproxy", "auth", "login", "claude_api"],
capture_output=True,
text=True,
timeout=120,
)
# Show browser URL in case browser didn't open automatically
for line in proc.stdout.splitlines():
if line.strip().startswith("https://"):
console.print(f" [dim]Visit: {line.strip()}[/dim]")
break
authed, msg = check_ccproxy_auth()
if authed:
console.print(f" [green]✓ OAuth: {msg}[/green]")
else:
console.print(f" [red]Authentication failed: {msg}[/red]")
except subprocess.TimeoutExpired:
console.print(" [red]Login timed out.[/red]")
except Exception as exc:
console.print(f" [red]Login error: {exc}[/red]")
_run_ccproxy_login("claude_api", "OAuth")
return auth_mode
@@ -874,7 +921,7 @@ def _step_openai_auth_mode(config: EvoScientistConfig) -> str:
Returns:
Selected auth mode: "api_key" or "oauth".
"""
from ..ccproxy_manager import _ccproxy_exe, is_ccproxy_available, check_ccproxy_auth
from ..ccproxy_manager import is_ccproxy_available, check_ccproxy_auth
ccproxy_available = is_ccproxy_available()
@@ -931,11 +978,20 @@ def _step_openai_auth_mode(config: EvoScientistConfig) -> str:
)
return "api_key"
# If OAuth selected, check auth status and offer login
# If OAuth selected, prompt for port and check auth status
if auth_mode == "oauth":
_prompt_ccproxy_port(config)
authed, msg = check_ccproxy_auth("codex")
if authed:
console.print(f" [green]✓ Codex OAuth: {msg}[/green]")
relogin = questionary.confirm(
"Re-authenticate to refresh credentials?",
default=False,
style=CONFIRM_STYLE,
qmark=QMARK,
).ask()
if relogin:
_run_ccproxy_login("codex", "Codex OAuth")
else:
console.print(f" [yellow]Codex OAuth not authenticated: {msg}[/yellow]")
login = questionary.confirm(
@@ -945,28 +1001,7 @@ def _step_openai_auth_mode(config: EvoScientistConfig) -> str:
qmark=QMARK,
).ask()
if login:
console.print(" [dim]Opening browser for authentication...[/dim]")
try:
proc = subprocess.run(
[_ccproxy_exe() or "ccproxy", "auth", "login", "codex"],
capture_output=True,
text=True,
timeout=120,
)
# Show browser URL in case browser didn't open automatically
for line in proc.stdout.splitlines():
if line.strip().startswith("https://"):
console.print(f" [dim]Visit: {line.strip()}[/dim]")
break
authed, msg = check_ccproxy_auth("codex")
if authed:
console.print(f" [green]✓ Codex OAuth: {msg}[/green]")
else:
console.print(f" [red]Authentication failed: {msg}[/red]")
except subprocess.TimeoutExpired:
console.print(" [red]Login timed out.[/red]")
except Exception as exc:
console.print(f" [red]Login error: {exc}[/red]")
_run_ccproxy_login("codex", "Codex OAuth")
return auth_mode
@@ -1782,28 +1817,18 @@ def validate_imessage() -> tuple[bool, str]:
def _install_ccproxy() -> bool:
"""Run pip install for ccproxy (evoscientist[oauth]).
Uses uv pip install when available (uv-managed envs don't ship pip).
Returns:
True if installation succeeded and ccproxy is available.
"""
from ..ccproxy_manager import is_ccproxy_available
try:
proc = subprocess.run(
[sys.executable, "-m", "pip", "install", "evoscientist[oauth]"],
capture_output=True,
text=True,
timeout=120,
)
if proc.returncode != 0:
console.print(f" [red]✗ Installation failed:[/red]\n{proc.stderr.strip()}")
return False
return is_ccproxy_available()
except subprocess.TimeoutExpired:
console.print(" [red]✗ Installation timed out.[/red]")
return False
except Exception as e:
console.print(f" [red]✗ Installation failed: {e}[/red]")
ok = _install_pip_package("evoscientist[oauth]")
if not ok:
console.print(" [red]✗ Installation failed.[/red]")
return False
return is_ccproxy_available()
def _install_imsg() -> bool:
+4
View File
@@ -195,6 +195,9 @@ class EvoScientistConfig:
# DM access control policy
dm_policy: str = "allowlist"
# ccproxy
ccproxy_port: int = 8000
# =============================================================================
# Config file operations
@@ -357,6 +360,7 @@ _ENV_MAPPINGS = {
"default_mode": "EVOSCIENTIST_DEFAULT_MODE",
"default_workdir": "EVOSCIENTIST_WORKSPACE_DIR",
"ui_backend": "EVOSCIENTIST_UI_BACKEND",
"ccproxy_port": "EVOSCIENTIST_CCPROXY_PORT",
}
+63
View File
@@ -91,6 +91,20 @@ class TestIsCcproxyRunning:
mock_get.return_value = MagicMock(status_code=200)
assert is_ccproxy_running(8000) is True
@patch("httpx.get")
def test_uses_health_live_endpoint(self, mock_get):
mock_get.return_value = MagicMock(status_code=200)
is_ccproxy_running(8000)
url = mock_get.call_args[0][0]
assert url == "http://127.0.0.1:8000/health/live"
@patch("httpx.get")
def test_uses_custom_port(self, mock_get):
mock_get.return_value = MagicMock(status_code=200)
is_ccproxy_running(7777)
url = mock_get.call_args[0][0]
assert url == "http://127.0.0.1:7777/health/live"
@patch("httpx.get")
def test_not_running(self, mock_get):
import httpx
@@ -98,6 +112,11 @@ class TestIsCcproxyRunning:
mock_get.side_effect = httpx.ConnectError("Connection refused")
assert is_ccproxy_running(8000) is False
@patch("httpx.get")
def test_non_200_means_not_running(self, mock_get):
mock_get.return_value = MagicMock(status_code=404)
assert is_ccproxy_running(8000) is False
# =============================================================================
# start_ccproxy
@@ -248,6 +267,7 @@ class TestMaybeStartCcproxy:
config = MagicMock()
config.anthropic_auth_mode = "oauth"
config.openai_auth_mode = "api_key"
config.ccproxy_port = 8000
result = maybe_start_ccproxy(config)
assert result is proc
@@ -285,6 +305,7 @@ class TestMaybeStartCcproxy:
config = MagicMock()
config.anthropic_auth_mode = "api_key"
config.openai_auth_mode = "oauth"
config.ccproxy_port = 8000
result = maybe_start_ccproxy(config)
assert result is proc
@@ -304,6 +325,7 @@ class TestMaybeStartCcproxy:
config = MagicMock()
config.anthropic_auth_mode = "oauth"
config.openai_auth_mode = "oauth"
config.ccproxy_port = 8000
result = maybe_start_ccproxy(config)
assert result is proc
@@ -331,3 +353,44 @@ class TestMaybeStartCcproxy:
config.openai_auth_mode = "oauth"
with pytest.raises(RuntimeError, match="Codex OAuth not authenticated"):
maybe_start_ccproxy(config)
@patch("EvoScientist.ccproxy_manager.setup_ccproxy_env")
@patch("EvoScientist.ccproxy_manager.ensure_ccproxy")
@patch("EvoScientist.ccproxy_manager.check_ccproxy_auth", return_value=(True, "OK"))
@patch("EvoScientist.ccproxy_manager.is_ccproxy_available", return_value=True)
def test_uses_config_ccproxy_port(
self, mock_avail, mock_auth, mock_ensure, mock_env
):
"""maybe_start_ccproxy passes config.ccproxy_port to ensure_ccproxy."""
proc = MagicMock()
mock_ensure.return_value = proc
config = MagicMock()
config.anthropic_auth_mode = "oauth"
config.openai_auth_mode = "api_key"
config.ccproxy_port = 7777
maybe_start_ccproxy(config)
mock_ensure.assert_called_once_with(7777)
@patch("EvoScientist.ccproxy_manager.check_ccproxy_auth", return_value=(True, "OK"))
@patch("EvoScientist.ccproxy_manager.is_ccproxy_available", return_value=True)
def test_invalid_port_raises(self, mock_avail, mock_auth):
"""maybe_start_ccproxy raises ValueError for out-of-range port."""
config = MagicMock()
config.anthropic_auth_mode = "oauth"
config.openai_auth_mode = "api_key"
config.ccproxy_port = 0
with pytest.raises(ValueError, match="Invalid ccproxy port"):
maybe_start_ccproxy(config)
@patch("EvoScientist.ccproxy_manager.check_ccproxy_auth", return_value=(True, "OK"))
@patch("EvoScientist.ccproxy_manager.is_ccproxy_available", return_value=True)
def test_port_too_large_raises(self, mock_avail, mock_auth):
config = MagicMock()
config.anthropic_auth_mode = "oauth"
config.openai_auth_mode = "api_key"
config.ccproxy_port = 99999
with pytest.raises(ValueError, match="Invalid ccproxy port"):
maybe_start_ccproxy(config)