feat: implement OAuth support for Anthropic using ccproxy and add related configuration options

This commit is contained in:
X-iZhang
2026-03-15 16:52:11 +00:00
parent 741eb709b9
commit 1ce2d059ca
10 changed files with 646 additions and 8 deletions
+207
View File
@@ -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
+35
View File
@@ -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)
+84 -3
View File
@@ -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:
+2
View File
@@ -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",
+43 -2
View File
@@ -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}
+1
View File
@@ -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",
+224
View File
@@ -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)
+2
View File
@@ -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",
)
+18
View File
@@ -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
+30 -3
View File
@@ -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")