From 1ce2d059ca1322449243bea47eeaec12d221c753 Mon Sep 17 00:00:00 2001 From: X-iZhang Date: Sun, 15 Mar 2026 16:52:11 +0000 Subject: [PATCH] feat: implement OAuth support for Anthropic using ccproxy and add related configuration options --- EvoScientist/ccproxy_manager.py | 207 +++++++++++++++++++++++++++++ EvoScientist/cli/commands.py | 35 +++++ EvoScientist/config/onboard.py | 87 ++++++++++++- EvoScientist/config/settings.py | 2 + EvoScientist/llm/models.py | 45 ++++++- pyproject.toml | 1 + tests/test_ccproxy_manager.py | 224 ++++++++++++++++++++++++++++++++ tests/test_cli_serve.py | 2 + tests/test_config.py | 18 +++ tests/test_llm.py | 33 ++++- 10 files changed, 646 insertions(+), 8 deletions(-) create mode 100644 EvoScientist/ccproxy_manager.py create mode 100644 tests/test_ccproxy_manager.py diff --git a/EvoScientist/ccproxy_manager.py b/EvoScientist/ccproxy_manager.py new file mode 100644 index 0000000..37bd9bf --- /dev/null +++ b/EvoScientist/ccproxy_manager.py @@ -0,0 +1,207 @@ +"""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 check_ccproxy_auth() -> tuple[bool, str]: + """Check if ccproxy has valid OAuth credentials. + + Returns: + (is_valid, message) tuple. + """ + try: + result = subprocess.run( + ["ccproxy", "auth", "status", "claude_api"], + capture_output=True, + text=True, + timeout=10, + ) + output = (result.stdout + result.stderr).strip() + # ccproxy auth status exits 0 when authed + if result.returncode == 0: + return True, output or "Authenticated" + return False, output 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 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" + + +# ============================================================================= +# 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``: + - ``oauth``: ccproxy must work — raises on failure. + - ``api_key``: no-op. + + Args: + config: An ``EvoScientistConfig`` instance. + + Returns: + Popen handle if we started ccproxy, None otherwise. + """ + auth_mode = getattr(config, "anthropic_auth_mode", "api_key") + if auth_mode != "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]'" + ) + + authed, msg = check_ccproxy_auth() + if not authed: + raise RuntimeError( + f"ccproxy OAuth not authenticated: {msg}\n" + "Run: ccproxy auth login claude_api" + ) + + proc = ensure_ccproxy() + setup_ccproxy_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 diff --git a/EvoScientist/cli/commands.py b/EvoScientist/cli/commands.py index 6bf5741..e92062c 100644 --- a/EvoScientist/cli/commands.py +++ b/EvoScientist/cli/commands.py @@ -478,6 +478,19 @@ def serve( config = get_effective_config(cli_overrides) apply_config_to_env(config) + # Auto-start ccproxy if OAuth mode + _ccproxy_proc_serve = None + if config.provider == "anthropic" and config.anthropic_auth_mode == "oauth": + try: + from ..ccproxy_manager import maybe_start_ccproxy, stop_ccproxy + _ccproxy_proc_serve = maybe_start_ccproxy(config) + if _ccproxy_proc_serve: + import atexit + atexit.register(stop_ccproxy, _ccproxy_proc_serve) + except RuntimeError as exc: + console.print(f"[red]{exc}[/red]") + raise typer.Exit(1) + if not config.channel_enabled: console.print("[red]No channels configured.[/red]") console.print("[dim]Run [bold]evosci channel setup[/bold] first.[/dim]") @@ -796,6 +809,11 @@ def _main_callback( no_thinking: bool = typer.Option(False, "--no-thinking", help="Disable thinking display"), auto_approve: bool = typer.Option(False, "--auto-approve", help="Auto-approve all tool executions without prompting"), ask_user: bool = typer.Option(False, "--ask-user", help="Enable agent to ask clarifying questions about your research preferences"), + auth_mode: Optional[str] = typer.Option( + None, + "--auth-mode", + help="Anthropic auth mode: api_key (default) or oauth (ccproxy).", + ), ui: Optional[str] = typer.Option( None, "--ui", @@ -828,10 +846,27 @@ def _main_callback( cli_overrides["auto_approve"] = True if ask_user: cli_overrides["enable_ask_user"] = True + if auth_mode: + if auth_mode not in ("api_key", "oauth"): + raise typer.BadParameter("--auth-mode must be 'api_key' or 'oauth'") + cli_overrides["anthropic_auth_mode"] = auth_mode config = get_effective_config(cli_overrides) apply_config_to_env(config) + # Auto-start ccproxy if OAuth mode + _ccproxy_proc = None + if config.provider == "anthropic" and config.anthropic_auth_mode == "oauth": + try: + from ..ccproxy_manager import maybe_start_ccproxy, stop_ccproxy + _ccproxy_proc = maybe_start_ccproxy(config) + if _ccproxy_proc: + import atexit + atexit.register(stop_ccproxy, _ccproxy_proc) + except RuntimeError as exc: + console.print(f"[red]{exc}[/red]") + raise typer.Exit(1) + show_thinking = config.show_thinking if not no_thinking else False effective_channel_thinking = config.channel_send_thinking and (not no_thinking) diff --git a/EvoScientist/config/onboard.py b/EvoScientist/config/onboard.py index a6b6584..8536a34 100644 --- a/EvoScientist/config/onboard.py +++ b/EvoScientist/config/onboard.py @@ -668,6 +668,78 @@ def _prompt_and_validate_api_key( return new_key if new_key else None +def _step_anthropic_auth_mode(config: EvoScientistConfig) -> str: + """Step 2a: Select Anthropic authentication mode (API key vs OAuth). + + Args: + config: Current configuration. + + Returns: + Selected auth mode: "api_key", "oauth", or "auto". + """ + from ..ccproxy_manager import 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" + + choices = [ + Choice(title="API Key (direct Anthropic access)", value="api_key"), + Choice(title="Claude Code OAuth (via ccproxy — no API key needed)", value="oauth"), + ] + + current = config.anthropic_auth_mode + if current not in ("api_key", "oauth"): + current = "api_key" + + auth_mode = questionary.select( + "Authentication mode:", + choices=choices, + default=current, + style=WIZARD_STYLE, + qmark=QMARK, + use_indicator=True, + ).ask() + + if auth_mode is None: + raise KeyboardInterrupt() + + # 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]") + else: + console.print(f" [yellow]OAuth not authenticated: {msg}[/yellow]") + login = questionary.confirm( + "Log in to Claude now?", + default=True, + style=CONFIRM_STYLE, + qmark=QMARK, + ).ask() + if login: + console.print(" [dim]Opening browser for authentication...[/dim]") + try: + subprocess.run( + ["ccproxy", "auth", "login", "claude_api"], + timeout=120, + ) + 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]") + + return auth_mode + + def _step_provider_api_key( config: EvoScientistConfig, provider: str, @@ -2091,8 +2163,13 @@ def run_onboard(skip_validation: bool = False) -> bool: ollama_url, ollama_detected_models = _step_ollama_base_url(config) config.ollama_base_url = ollama_url - # Step 2b: Provider API Key (skip for Ollama — no key needed) - # Maps provider name → config attribute for the API key. + # Step 2b: Auth mode (Anthropic only — API key vs OAuth) + if provider == "anthropic": + auth_mode = _step_anthropic_auth_mode(config) + config.anthropic_auth_mode = auth_mode + + # Step 2c: Provider API Key (skip for Ollama — no key needed, + # and for Anthropic pure OAuth — key provided by ccproxy) _PROVIDER_KEY_ATTR = { "anthropic": "anthropic_api_key", "nvidia": "nvidia_api_key", @@ -2103,7 +2180,11 @@ def run_onboard(skip_validation: bool = False) -> bool: "zhipu-code": "zhipu_api_key", "custom": "custom_api_key", } - if provider != "ollama": + _skip_api_key = ( + provider == "ollama" + or (provider == "anthropic" and config.anthropic_auth_mode == "oauth") + ) + if not _skip_api_key: new_key = _step_provider_api_key(config, provider, skip_validation) key_attr = _PROVIDER_KEY_ATTR.get(provider, "openai_api_key") if new_key is not None: diff --git a/EvoScientist/config/settings.py b/EvoScientist/config/settings.py index 01c5b3e..f240cdb 100644 --- a/EvoScientist/config/settings.py +++ b/EvoScientist/config/settings.py @@ -61,6 +61,7 @@ class EvoScientistConfig: # API Keys anthropic_api_key: str = "" anthropic_base_url: str = "" + anthropic_auth_mode: str = "api_key" # "api_key" | "oauth" openai_api_key: str = "" nvidia_api_key: str = "" google_api_key: str = "" @@ -331,6 +332,7 @@ def list_config() -> dict[str, Any]: _ENV_MAPPINGS = { "anthropic_api_key": "ANTHROPIC_API_KEY", "anthropic_base_url": "ANTHROPIC_BASE_URL", + "anthropic_auth_mode": "EVOSCIENTIST_ANTHROPIC_AUTH_MODE", "openai_api_key": "OPENAI_API_KEY", "nvidia_api_key": "NVIDIA_API_KEY", "google_api_key": "GOOGLE_API_KEY", diff --git a/EvoScientist/llm/models.py b/EvoScientist/llm/models.py index 3a03a85..572fa3a 100644 --- a/EvoScientist/llm/models.py +++ b/EvoScientist/llm/models.py @@ -13,6 +13,40 @@ from typing import Any from langchain.chat_models import init_chat_model +# --------------------------------------------------------------------------- +# Patch: langchain-anthropic (>=1.3.4) calls .model_dump() on +# context_management / container objects returned by the Anthropic SDK. +# Proxies like ccproxy may return plain dicts which lack that method. +# We wrap the class method to pre-convert dicts before the original runs. +# --------------------------------------------------------------------------- +def _patch_anthropic_proxy_compat() -> None: + try: + import types as _types + from langchain_anthropic.chat_models import ChatAnthropic as _CA + + _orig = _CA._make_message_chunk_from_anthropic_event + + def _safe(self: Any, event: Any, *args: Any, **kwargs: Any) -> Any: + for obj, attrs in [ + (event, ("context_management",)), + (getattr(event, "delta", None), ("container",)), + ]: + if obj is None: + continue + for attr in attrs: + val = getattr(obj, attr, None) + if isinstance(val, dict): + d = val.copy() + setattr(obj, attr, + _types.SimpleNamespace(model_dump=lambda **kw: d)) + return _orig(self, event, *args, **kwargs) + + _CA._make_message_chunk_from_anthropic_event = _safe + except Exception: + pass + +_patch_anthropic_proxy_compat() + _SILICONFLOW_BASE_URL = "https://api.siliconflow.cn/v1" _OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1" _ZHIPU_BASE_URL = "https://open.bigmodel.cn/api/paas/v4" @@ -123,9 +157,16 @@ def _apply_auto_config( """ # Anthropic: extended thinking if provider == "anthropic" and "thinking" not in kwargs: + base_url = os.environ.get("ANTHROPIC_BASE_URL", "") + _is_proxy = "127.0.0.1" in base_url or "localhost" in base_url if model_id.endswith("4-6"): - kwargs["thinking"] = {"type": "adaptive"} - kwargs.setdefault("effort", "max") + if _is_proxy: + # ccproxy manages thinking internally; don't set it here + # to avoid 422 errors with thinking content blocks in history + pass + else: + kwargs["thinking"] = {"type": "adaptive"} + kwargs.setdefault("effort", "max") else: kwargs["thinking"] = {"type": "enabled", "budget_tokens": 10000} diff --git a/pyproject.toml b/pyproject.toml index d3f4be4..2538ad2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,6 +61,7 @@ discord = ["discord.py>=2.3"] slack = ["slack-sdk>=3.27", "aiohttp>=3.9"] wechat = ["pycryptodome>=3.20"] qq = ["qq-botpy>=1.0"] +oauth = ["ccproxy-api>=0.2.4"] all-channels = [ "python-telegram-bot>=21.0", "discord.py>=2.3", diff --git a/tests/test_ccproxy_manager.py b/tests/test_ccproxy_manager.py new file mode 100644 index 0000000..c755b64 --- /dev/null +++ b/tests/test_ccproxy_manager.py @@ -0,0 +1,224 @@ +"""Tests for ccproxy_manager module.""" + +import os +from unittest.mock import MagicMock, patch + +import pytest + +from EvoScientist.ccproxy_manager import ( + is_ccproxy_available, + check_ccproxy_auth, + is_ccproxy_running, + start_ccproxy, + stop_ccproxy, + ensure_ccproxy, + setup_ccproxy_env, + maybe_start_ccproxy, +) + + +# ============================================================================= +# is_ccproxy_available +# ============================================================================= + + +class TestIsCcproxyAvailable: + @patch("shutil.which", return_value="/usr/local/bin/ccproxy") + def test_found(self, mock_which): + assert is_ccproxy_available() is True + mock_which.assert_called_once_with("ccproxy") + + @patch("shutil.which", return_value=None) + def test_not_found(self, mock_which): + assert is_ccproxy_available() is False + + +# ============================================================================= +# check_ccproxy_auth +# ============================================================================= + + +class TestCheckCcproxyAuth: + @patch("subprocess.run") + def test_valid_auth(self, mock_run): + mock_run.return_value = MagicMock( + returncode=0, stdout="Authenticated as user@example.com", stderr="" + ) + valid, msg = check_ccproxy_auth() + assert valid is True + assert "Authenticated" in msg + + @patch("subprocess.run") + def test_invalid_auth(self, mock_run): + mock_run.return_value = MagicMock( + returncode=1, stdout="", stderr="Not authenticated" + ) + valid, msg = check_ccproxy_auth() + assert valid is False + assert "Not authenticated" in msg + + @patch("subprocess.run", side_effect=FileNotFoundError) + def test_missing_binary(self, mock_run): + valid, msg = check_ccproxy_auth() + assert valid is False + assert "not found" in msg + + +# ============================================================================= +# is_ccproxy_running +# ============================================================================= + + +class TestIsCcproxyRunning: + @patch("httpx.get") + def test_running(self, mock_get): + mock_get.return_value = MagicMock(status_code=200) + assert is_ccproxy_running(8000) is True + + @patch("httpx.get") + def test_not_running(self, mock_get): + import httpx + mock_get.side_effect = httpx.ConnectError("Connection refused") + assert is_ccproxy_running(8000) is False + + +# ============================================================================= +# start_ccproxy +# ============================================================================= + + +class TestStartCcproxy: + @patch("EvoScientist.ccproxy_manager.is_ccproxy_running") + @patch("subprocess.Popen") + def test_success(self, mock_popen, mock_running): + proc = MagicMock() + proc.poll.return_value = None + mock_popen.return_value = proc + # First call: not running, second call: running + mock_running.side_effect = [True] + + result = start_ccproxy(8000) + assert result is proc + + @patch("EvoScientist.ccproxy_manager.is_ccproxy_running", return_value=False) + @patch("EvoScientist.ccproxy_manager.time") + @patch("subprocess.Popen") + def test_timeout(self, mock_popen, mock_time, mock_running): + proc = MagicMock() + proc.poll.return_value = None + mock_popen.return_value = proc + # Simulate time passing beyond deadline + mock_time.monotonic.side_effect = [0, 0, 11] + mock_time.sleep = MagicMock() + + with pytest.raises(RuntimeError, match="did not become healthy"): + start_ccproxy(8000) + + @patch("subprocess.Popen", side_effect=FileNotFoundError) + def test_missing_binary(self, mock_popen): + with pytest.raises(FileNotFoundError): + start_ccproxy(8000) + + +# ============================================================================= +# ensure_ccproxy +# ============================================================================= + + +class TestEnsureCcproxy: + @patch("EvoScientist.ccproxy_manager.is_ccproxy_running", return_value=True) + def test_already_running(self, mock_running): + result = ensure_ccproxy(8000) + assert result is None + + @patch("EvoScientist.ccproxy_manager.start_ccproxy") + @patch("EvoScientist.ccproxy_manager.is_ccproxy_running", return_value=False) + def test_needs_start(self, mock_running, mock_start): + proc = MagicMock() + mock_start.return_value = proc + result = ensure_ccproxy(8000) + assert result is proc + mock_start.assert_called_once_with(8000) + + +# ============================================================================= +# setup_ccproxy_env +# ============================================================================= + + +class TestSetupCcproxyEnv: + def test_sets_vars(self, monkeypatch): + monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False) + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + + setup_ccproxy_env(8000) + + assert os.environ["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8000/claude" + assert os.environ["ANTHROPIC_API_KEY"] == "ccproxy-oauth" + + def test_overrides_existing(self, monkeypatch): + """Force-sets vars even if already configured (oauth takes priority).""" + monkeypatch.setenv("ANTHROPIC_BASE_URL", "http://custom:9999") + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-real-key") + + setup_ccproxy_env(8000) + + assert os.environ["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8000/claude" + assert os.environ["ANTHROPIC_API_KEY"] == "ccproxy-oauth" + + +# ============================================================================= +# stop_ccproxy +# ============================================================================= + + +class TestStopCcproxy: + def test_none_is_noop(self): + stop_ccproxy(None) # Should not raise + + def test_terminates_process(self): + proc = MagicMock() + stop_ccproxy(proc) + proc.terminate.assert_called_once() + proc.wait.assert_called_once_with(timeout=5) + + +# ============================================================================= +# maybe_start_ccproxy +# ============================================================================= + + +class TestMaybeStartCcproxy: + def test_api_key_mode_noop(self): + config = MagicMock() + config.anthropic_auth_mode = "api_key" + assert maybe_start_ccproxy(config) is None + + @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_oauth_mode_starts(self, mock_avail, mock_auth, mock_ensure, mock_env): + proc = MagicMock() + mock_ensure.return_value = proc + config = MagicMock() + config.anthropic_auth_mode = "oauth" + + result = maybe_start_ccproxy(config) + assert result is proc + mock_env.assert_called_once() + + @patch("EvoScientist.ccproxy_manager.is_ccproxy_available", return_value=False) + def test_oauth_mode_raises_no_binary(self, mock_avail): + config = MagicMock() + config.anthropic_auth_mode = "oauth" + with pytest.raises(RuntimeError, match="not found"): + maybe_start_ccproxy(config) + + @patch("EvoScientist.ccproxy_manager.check_ccproxy_auth", return_value=(False, "expired")) + @patch("EvoScientist.ccproxy_manager.is_ccproxy_available", return_value=True) + def test_oauth_mode_raises_no_auth(self, mock_avail, mock_auth): + config = MagicMock() + config.anthropic_auth_mode = "oauth" + with pytest.raises(RuntimeError, match="not authenticated"): + maybe_start_ccproxy(config) diff --git a/tests/test_cli_serve.py b/tests/test_cli_serve.py index 71cacc9..bef2c0c 100644 --- a/tests/test_cli_serve.py +++ b/tests/test_cli_serve.py @@ -16,6 +16,8 @@ def _make_config( channel_enabled="telegram", default_workdir=default_workdir, channel_send_thinking=channel_send_thinking, + provider="anthropic", + anthropic_auth_mode="api_key", ) diff --git a/tests/test_config.py b/tests/test_config.py index 6a0e26e..4aea5b8 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -81,6 +81,16 @@ class TestEvoScientistConfig: assert config.imessage_enabled is False assert config.imessage_allowed_senders == "" + def test_auth_mode_default(self): + """Test that anthropic_auth_mode defaults to api_key.""" + config = EvoScientistConfig() + assert config.anthropic_auth_mode == "api_key" + + def test_auth_mode_set(self): + """Test that anthropic_auth_mode can be set.""" + config = EvoScientistConfig(anthropic_auth_mode="oauth") + assert config.anthropic_auth_mode == "oauth" + def test_custom_values(self): """Test that custom values can be set.""" config = EvoScientistConfig( @@ -328,6 +338,14 @@ class TestPriorityChain: config = get_effective_config() assert config.anthropic_api_key == "env-key" + def test_env_auth_mode_override(self, temp_config_dir, monkeypatch): + """Test auth mode from env overrides file.""" + save_config(EvoScientistConfig(anthropic_auth_mode="api_key")) + monkeypatch.setenv("EVOSCIENTIST_ANTHROPIC_AUTH_MODE", "oauth") + + config = get_effective_config() + assert config.anthropic_auth_mode == "oauth" + # ============================================================================= # Test apply_config_to_env diff --git a/tests/test_llm.py b/tests/test_llm.py index fe5ec87..719523e 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -405,8 +405,8 @@ class TestThirdPartyRouting: assert call_kwargs["model_provider"] == "anthropic" assert call_kwargs["base_url"] == "http://localhost:8000/api/v1" assert call_kwargs["api_key"] == "sk-dummy" - # Should still get anthropic auto-config (thinking) - assert "thinking" in call_kwargs + # Proxy mode: thinking skipped for 4-6 models (ccproxy manages it) + assert "thinking" not in call_kwargs @patch("EvoScientist.llm.models.init_chat_model") def test_anthropic_no_base_url_when_unset(self, mock_init, monkeypatch): @@ -450,9 +450,36 @@ class TestAutoConfig: assert call_kwargs["thinking"] == {"type": "enabled", "budget_tokens": 10000} @patch("EvoScientist.llm.models.init_chat_model") - def test_anthropic_4_6_adaptive_thinking(self, mock_init): + def test_anthropic_4_6_adaptive_thinking(self, mock_init, monkeypatch): """Anthropic 4-6 models get adaptive thinking with max effort.""" mock_init.return_value = "mock_model" + monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False) + + get_chat_model("claude-sonnet-4-6") + + call_kwargs = mock_init.call_args[1] + assert call_kwargs["thinking"] == {"type": "adaptive"} + assert call_kwargs["effort"] == "max" + + @patch("EvoScientist.llm.models.init_chat_model") + def test_anthropic_4_6_proxy_no_thinking(self, mock_init, monkeypatch): + """Anthropic 4-6 models via proxy skip thinking (ccproxy manages it).""" + mock_init.return_value = "mock_model" + monkeypatch.setenv("ANTHROPIC_BASE_URL", "http://127.0.0.1:8000") + monkeypatch.setenv("ANTHROPIC_API_KEY", "ccproxy-oauth") + + get_chat_model("claude-sonnet-4-6") + + call_kwargs = mock_init.call_args[1] + assert "thinking" not in call_kwargs + assert "effort" not in call_kwargs + + @patch("EvoScientist.llm.models.init_chat_model") + def test_anthropic_4_6_no_proxy_no_downgrade(self, mock_init, monkeypatch): + """Anthropic 4-6 models without proxy still get adaptive thinking.""" + mock_init.return_value = "mock_model" + monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://api.anthropic.com") + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-real") get_chat_model("claude-sonnet-4-6")