diff --git a/EvoScientist/ccproxy_manager.py b/EvoScientist/ccproxy_manager.py index 2019051..3907f77 100644 --- a/EvoScientist/ccproxy_manager.py +++ b/EvoScientist/ccproxy_manager.py @@ -30,9 +30,26 @@ _DEFAULT_PORT = 8000 # ============================================================================= +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 on PATH.""" - return shutil.which("ccproxy") is not None + """Check whether the ``ccproxy`` CLI binary is available.""" + return _ccproxy_exe() is not None def _summarize_auth_output(raw: str) -> str: @@ -77,23 +94,35 @@ def check_ccproxy_auth(provider: str = "claude_api") -> tuple[bool, str]: (is_valid, message) tuple. """ try: + exe = _ccproxy_exe() or "ccproxy" result = subprocess.run( - ["ccproxy", "auth", "status", provider], + [exe, "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 + raw = (result.stdout + result.stderr).strip() clean = _re.sub(r"\x1b\[[0-9;]*m", "", raw) - return False, clean or "Not authenticated" + + # 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: @@ -131,8 +160,9 @@ def start_ccproxy(port: int = _DEFAULT_PORT) -> subprocess.Popen: RuntimeError: If ccproxy fails to become healthy within 10 seconds. FileNotFoundError: If ccproxy binary is not found. """ + exe = _ccproxy_exe() or "ccproxy" proc = subprocess.Popen( - ["ccproxy", "serve", "--port", str(port)], + [exe, "serve", "--port", str(port)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) diff --git a/EvoScientist/config/onboard.py b/EvoScientist/config/onboard.py index cbe9e05..55680a6 100644 --- a/EvoScientist/config/onboard.py +++ b/EvoScientist/config/onboard.py @@ -768,19 +768,16 @@ def _step_anthropic_auth_mode(config: EvoScientistConfig) -> str: Returns: Selected auth mode: "api_key", "oauth", or "auto". """ - from ..ccproxy_manager import is_ccproxy_available, check_ccproxy_auth + from ..ccproxy_manager import _ccproxy_exe, is_ccproxy_available, check_ccproxy_auth - if not is_ccproxy_available(): - console.print( - " [dim]OAuth via ccproxy not available. " - 'Install with: pip install "evoscientist[oauth]"[/dim]' - ) - return "api_key" + ccproxy_available = is_ccproxy_available() choices = [ Choice(title="API Key (direct Anthropic access)", value="api_key"), Choice( - title="Claude Code OAuth (via ccproxy — no API key needed)", value="oauth" + title="Claude Code OAuth (via ccproxy — no API key needed)" + + ("" if ccproxy_available else " [requires: pip install evoscientist[oauth]]"), + value="oauth", ), ] @@ -800,6 +797,30 @@ def _step_anthropic_auth_mode(config: EvoScientistConfig) -> str: if auth_mode is None: raise KeyboardInterrupt() + if auth_mode == "oauth" and not ccproxy_available: + console.print(" [yellow]✗ ccproxy not installed[/yellow]") + console.print() + install = questionary.confirm( + 'Install ccproxy now? (pip install "evoscientist[oauth]")', + default=True, + style=WIZARD_STYLE, + qmark=f" {QMARK}", + ).ask() + if install is None: + raise KeyboardInterrupt() + if install: + console.print() + if _install_ccproxy(): + console.print(" [green]✓ ccproxy installed successfully.[/green]") + else: + console.print(" [yellow]Falling back to API key mode.[/yellow]") + return "api_key" + else: + console.print( + ' [dim]Skipped. Install manually: pip install "evoscientist[oauth]"[/dim]' + ) + return "api_key" + # If OAuth selected, check auth status and offer login if auth_mode in ("oauth", "auto"): authed, msg = check_ccproxy_auth() @@ -816,10 +837,17 @@ def _step_anthropic_auth_mode(config: EvoScientistConfig) -> str: if login: console.print(" [dim]Opening browser for authentication...[/dim]") try: - subprocess.run( - ["ccproxy", "auth", "login", "claude_api"], + 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]") @@ -842,19 +870,16 @@ def _step_openai_auth_mode(config: EvoScientistConfig) -> str: Returns: Selected auth mode: "api_key" or "oauth". """ - from ..ccproxy_manager import is_ccproxy_available, check_ccproxy_auth + from ..ccproxy_manager import _ccproxy_exe, is_ccproxy_available, check_ccproxy_auth - if not is_ccproxy_available(): - console.print( - " [dim]OAuth via ccproxy not available. " - 'Install with: pip install "evoscientist[oauth]"[/dim]' - ) - return "api_key" + ccproxy_available = is_ccproxy_available() choices = [ Choice(title="API Key (direct OpenAI access)", value="api_key"), Choice( - title="Codex OAuth (via ccproxy — no API key needed)", value="oauth" + title="Codex OAuth (via ccproxy — no API key needed)" + + ("" if ccproxy_available else " [requires: pip install evoscientist[oauth]]"), + value="oauth", ), ] @@ -874,6 +899,30 @@ def _step_openai_auth_mode(config: EvoScientistConfig) -> str: if auth_mode is None: raise KeyboardInterrupt() + if auth_mode == "oauth" and not ccproxy_available: + console.print(" [yellow]✗ ccproxy not installed[/yellow]") + console.print() + install = questionary.confirm( + 'Install ccproxy now? (pip install "evoscientist[oauth]")', + default=True, + style=WIZARD_STYLE, + qmark=f" {QMARK}", + ).ask() + if install is None: + raise KeyboardInterrupt() + if install: + console.print() + if _install_ccproxy(): + console.print(" [green]✓ ccproxy installed successfully.[/green]") + else: + console.print(" [yellow]Falling back to API key mode.[/yellow]") + return "api_key" + else: + console.print( + ' [dim]Skipped. Install manually: pip install "evoscientist[oauth]"[/dim]' + ) + return "api_key" + # If OAuth selected, check auth status and offer login if auth_mode == "oauth": authed, msg = check_ccproxy_auth("codex") @@ -890,10 +939,17 @@ def _step_openai_auth_mode(config: EvoScientistConfig) -> str: if login: console.print(" [dim]Opening browser for authentication...[/dim]") try: - subprocess.run( - ["ccproxy", "auth", "login", "codex"], + 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]") @@ -1715,6 +1771,33 @@ def validate_imessage() -> tuple[bool, str]: return True, f"imsg{version_str} at {cli_path}" +def _install_ccproxy() -> bool: + """Run pip install for ccproxy (evoscientist[oauth]). + + 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]") + return False + + def _install_imsg() -> bool: """Run brew install for imsg CLI. @@ -2353,6 +2436,11 @@ def run_onboard(skip_validation: bool = False) -> bool: elif provider == "openai": auth_mode = _step_openai_auth_mode(config) config.openai_auth_mode = auth_mode + else: + # Non-Anthropic/OpenAI provider: reset OAuth modes to avoid + # stale oauth config triggering ccproxy requirement on startup + config.anthropic_auth_mode = "api_key" + config.openai_auth_mode = "api_key" # Step 2c: Provider API Key (skip for Ollama — no key needed, # and for Anthropic/OpenAI pure OAuth — key provided by ccproxy) diff --git a/EvoScientist/llm/models.py b/EvoScientist/llm/models.py index 879ca40..b7680ef 100644 --- a/EvoScientist/llm/models.py +++ b/EvoScientist/llm/models.py @@ -220,9 +220,7 @@ def _apply_auto_config( base_url = os.environ.get("OPENAI_BASE_URL", "") _is_openai_proxy = "127.0.0.1" in base_url or "localhost" in base_url if _is_openai_proxy: - # ccproxy forces store=False. Setting `reasoning` triggers - # langchain-openai's Responses API path, which produces - # rs_ summary items that 404 on multi-turn. Skip entirely. + # Skip reasoning kwarg for ccproxy — not needed and may cause issues. pass else: kwargs["reasoning"] = {"effort": "high", "summary": "auto"} @@ -308,8 +306,8 @@ def get_chat_model( kwargs["base_url"] = base_url _is_openai_proxy = "127.0.0.1" in base_url or "localhost" in base_url if _is_openai_proxy: - kwargs.setdefault("streaming", False) # ccproxy streaming incompatible - kwargs.setdefault("use_responses_api", False) # force Chat Completions + kwargs.setdefault("streaming", False) # ccproxy streaming format incompatible with langchain-openai + kwargs.setdefault("use_responses_api", True) # ccproxy Chat Completions does not support tool calling; Responses API does api_key = os.environ.get("OPENAI_API_KEY", "") if api_key: kwargs["api_key"] = api_key diff --git a/README.md b/README.md index bf8fc83..7768e27 100644 --- a/README.md +++ b/README.md @@ -207,7 +207,8 @@ EvoSci onboard ``` > [!TIP] -> It walks you through provider selection, key validation, model choice, and workspace mode. +> It walks you through provider selection, key validation, model choice, and workspace mode. +> Supports OAuth sign-in for [Claude Code](https://claude.com/product/claude-code) and [Codex ClI](https://developers.openai.com/codex/cli/) users — no API key needed. ![onboard](.github/assets/EvoScientist_onboard.png) @@ -413,7 +414,7 @@ Coming soon: - [x] 👋 Human-in-the-loop action approval - [x] 🦾 Agent-initiated human clarification - [x] 📑 Technical report on the way -- [ ] 🔐 OAuth sign-in (Anthropic, OpenAI, etc.) +- [x] 🔐 OAuth sign-in (Anthropic, OpenAI, etc.) - [ ] 📺 Web app with workspace UI - [ ] 📹 Demo and tutorial in the works - [ ] 📊 Benchmark suite to be released diff --git a/README.zh-CN.md b/README.zh-CN.md index 1edcb9a..014bfee 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -217,6 +217,7 @@ EvoSci onboard > [!TIP] > 向导将引导你完成供应商选择、密钥验证、模型选择和工作区模式设置。 +> 支持 [Claude Code](https://claude.com/product/claude-code) 和 [Codex CLI](https://developers.openai.com/codex/cli/) 用户通过 OAuth 直连——无需 API Key。 ![onboard](.github/assets/EvoScientist_onboard.png) @@ -422,7 +423,7 @@ channel_enabled: "telegram,slack,feishu,qq" - [x] 👋 Human-in-the-loop 操作审批 - [x] 🦾 智能体主动向人类澄清确认 - [x] 📑 技术报告已发布 -- [ ] 🔐 OAuth 登录(Anthropic、OpenAI 等) +- [x] 🔐 OAuth 登录(Anthropic、OpenAI 等) - [ ] 📺 带工作区的 Web 应用界面 - [ ] 📹 Demo 与教程正在制作中 - [ ] 📊 基准测试套件即将推出 diff --git a/tests/test_ccproxy_manager.py b/tests/test_ccproxy_manager.py index b10c788..b0c5fcd 100644 --- a/tests/test_ccproxy_manager.py +++ b/tests/test_ccproxy_manager.py @@ -29,8 +29,10 @@ class TestIsCcproxyAvailable: assert is_ccproxy_available() is True mock_which.assert_called_once_with("ccproxy") + @patch("os.access", return_value=False) + @patch("os.path.isfile", return_value=False) @patch("shutil.which", return_value=None) - def test_not_found(self, mock_which): + def test_not_found(self, mock_which, mock_isfile, mock_access): assert is_ccproxy_available() is False @@ -49,7 +51,8 @@ class TestCheckCcproxyAuth: assert valid is True assert "Authenticated" in msg mock_run.assert_called_once() - assert mock_run.call_args[0][0] == ["ccproxy", "auth", "status", "claude_api"] + cmd = mock_run.call_args[0][0] + assert cmd[1:] == ["auth", "status", "claude_api"] @patch("subprocess.run") def test_valid_auth_codex(self, mock_run): @@ -58,7 +61,8 @@ class TestCheckCcproxyAuth: ) valid, msg = check_ccproxy_auth("codex") assert valid is True - assert mock_run.call_args[0][0] == ["ccproxy", "auth", "status", "codex"] + cmd = mock_run.call_args[0][0] + assert cmd[1:] == ["auth", "status", "codex"] @patch("subprocess.run") def test_invalid_auth(self, mock_run):