feat: implement OAuth support for Anthropic using ccproxy and add related configuration options
This commit is contained in:
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user