diff --git a/EvoScientist/ccproxy_manager.py b/EvoScientist/ccproxy_manager.py index 1c1ed1e..1dfc4ec 100644 --- a/EvoScientist/ccproxy_manager.py +++ b/EvoScientist/ccproxy_manager.py @@ -282,88 +282,6 @@ def setup_codex_env(port: int) -> None: # ============================================================================= -def _patch_ccproxy_oauth_header() -> None: - """Auto-patch ccproxy's adapter to send the correct OAuth beta header. - - ccproxy 0.2.4 hardcodes ``computer-use-2025-01-24`` as the - ``anthropic-beta`` header, which causes two problems: - - Missing ``oauth-2025-04-20`` → 401 from Anthropic - - ``computer-use-2025-01-24`` incompatible with OAuth auth → 400 - - The ccproxy binary may use a different Python environment than the one - running EvoScientist, so we resolve the adapter path via the ccproxy - binary's shebang line rather than the current Python's import system. - - This patch is idempotent and places the header AFTER cli_headers so it - cannot be overridden. - """ - import pathlib - import re - - try: - ccproxy_bin = _ccproxy_exe() - if not ccproxy_bin: - return - - # Find the Python interpreter used by the ccproxy binary via shebang - shebang = pathlib.Path(ccproxy_bin).read_text().splitlines()[0] - python_exe = shebang.lstrip("#!").strip() - - # Ask that Python where ccproxy's adapter lives - result = subprocess.run( - [ - python_exe, - "-c", - "import inspect, ccproxy.plugins.claude_api.adapter as m; print(inspect.getfile(m))", - ], - capture_output=True, - text=True, - timeout=10, - ) - if result.returncode != 0: - return - src_file = pathlib.Path(result.stdout.strip()) - if not src_file.exists(): - return - - text = src_file.read_text() - - # Check if already correctly patched (oauth header set after cli_headers) - correct = 'filtered_headers["anthropic-beta"] = "oauth-2025-04-20"' - cli_marker = "cli_headers = self._collect_cli_headers()" - if correct in text: - # Verify it's placed after cli_headers - if text.index(correct) > text.index(cli_marker): - return # Already correctly patched - - # Move/replace anthropic-beta assignment to after cli_headers loop, - # and set only oauth-2025-04-20 (computer-use-* is incompatible). - # Step 1: remove any existing filtered_headers["anthropic-beta"] line - patched = re.sub( - r'\s*filtered_headers\["anthropic-beta"\]\s*=\s*"[^"]*"\n', - "\n", - text, - ) - # Step 2: insert correct assignment after the cli_headers block - insert_after = "filtered_headers[lk] = value\n" - replacement = ( - "filtered_headers[lk] = value\n\n" - " # oauth-2025-04-20: required for OAuth Bearer token auth (Anthropic 2026-03)\n" - ' filtered_headers["anthropic-beta"] = "oauth-2025-04-20"\n' - ) - patched = patched.replace(insert_after, replacement, 1) - - if patched == text: - return - - src_file.write_text(patched) - for pyc in src_file.parent.glob("__pycache__/adapter*.pyc"): - pyc.unlink(missing_ok=True) - logger.info("Auto-patched ccproxy adapter: set anthropic-beta=oauth-2025-04-20") - except Exception as exc: - logger.warning("Could not auto-patch ccproxy adapter: %s", exc) - - def maybe_start_ccproxy(config: EvoScientistConfig) -> subprocess.Popen | None: """High-level: conditionally start ccproxy based on config. @@ -414,9 +332,6 @@ def maybe_start_ccproxy(config: EvoScientistConfig) -> subprocess.Popen | None: if not (1 <= port <= 65535): raise ValueError(f"Invalid ccproxy port: {port}. Must be between 1 and 65535.") - # Auto-patch ccproxy adapter to fix OAuth header compatibility - _patch_ccproxy_oauth_header() - # Start ccproxy (single process serves both providers) proc = ensure_ccproxy(port) diff --git a/EvoScientist/llm/models.py b/EvoScientist/llm/models.py index b3aed1c..848edc8 100644 --- a/EvoScientist/llm/models.py +++ b/EvoScientist/llm/models.py @@ -10,7 +10,6 @@ convenient short names for common models. from __future__ import annotations import os -import re from typing import Any from langchain.chat_models import init_chat_model @@ -55,16 +54,21 @@ def _patch_anthropic_proxy_compat() -> None: _patch_anthropic_proxy_compat() -# --------------------------------------------------------------------------- -# Patch: ccproxy Codex embeds thinking as ... tags -# inside the content string. Strip these so they don't appear in output. -# --------------------------------------------------------------------------- -_THINKING_TAG_RE = re.compile(r".*?\s*", re.DOTALL) +def _is_ccproxy_codex() -> bool: + """Return True if the OpenAI endpoint is ccproxy's Codex adapter. -def strip_thinking_tags(content: str) -> str: - """Remove ``...`` tags from ccproxy response content.""" - return _THINKING_TAG_RE.sub("", content) + Checks for the ccproxy-specific markers set by ``setup_codex_env()`` + in ``ccproxy_manager.py``: the sentinel API key and the ``/codex/v1`` + path. Plain localhost endpoints (vLLM, Ollama, etc.) are not affected. + """ + base_url = os.environ.get("OPENAI_BASE_URL", "") + api_key = os.environ.get("OPENAI_API_KEY", "") + return ( + ("127.0.0.1" in base_url or "localhost" in base_url) + and api_key == "ccproxy-oauth" + and "/codex/" in base_url + ) _SKIP_CONTENT_TYPES = frozenset({"thinking", "reasoning", "reasoning_content"}) @@ -320,16 +324,15 @@ def _apply_auto_config( # Anthropic: extended thinking if provider == "anthropic" and "thinking" not in kwargs: _supports_thinking = original_provider in _THINKING_CAPABLE_PROVIDERS - # Only check ANTHROPIC_BASE_URL for proxy detection on native Anthropic - # (routed providers have their own base_url set in kwargs already). + # Detect local proxy (e.g. ccproxy): thinking blocks in conversation + # history cause 422 errors because the proxy doesn't accept 'thinking' + # as a valid content block type on round-trip. if not is_third_party: base_url = os.environ.get("ANTHROPIC_BASE_URL", "") _is_proxy = "127.0.0.1" in base_url or "localhost" in base_url else: _is_proxy = False if _is_proxy or (is_third_party and not _supports_thinking): - # ccproxy / generic third-party: skip thinking to avoid - # 422 errors with thinking content blocks in history pass elif model_id.endswith("4-6"): kwargs["thinking"] = {"type": "adaptive"} @@ -339,10 +342,8 @@ def _apply_auto_config( # OpenAI (native, not third-party routed): reasoning if provider == "openai" and not is_third_party and "reasoning" not in kwargs: - 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: - # Skip reasoning kwarg for ccproxy — not needed and may cause issues. + if _is_ccproxy_codex(): + # ccproxy uses Chat Completions which doesn't support reasoning. pass else: kwargs["reasoning"] = {"effort": "high", "summary": "auto"} @@ -429,14 +430,16 @@ def get_chat_model( base_url = os.environ.get("OPENAI_BASE_URL", "") if base_url: kwargs["base_url"] = base_url - _is_openai_proxy = "127.0.0.1" in base_url or "localhost" in base_url + _is_openai_proxy = _is_ccproxy_codex() if _is_openai_proxy: - 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 + # Default to Chat Completions for ccproxy: its Chat + # Completions → Responses API converter handles system messages + # correctly; its native Responses API endpoint does not. + # (User can override via EVOSCIENTIST_USE_RESPONSES_API=true.) + kwargs.setdefault("use_responses_api", False) + # Default streaming off: ccproxy duplicates tool call names + # in streaming Chat Completions chunks. + kwargs.setdefault("streaming", False) api_key = os.environ.get("OPENAI_API_KEY", "") if api_key: kwargs["api_key"] = api_key diff --git a/EvoScientist/stream/events.py b/EvoScientist/stream/events.py index 55383e8..6bc39c5 100644 --- a/EvoScientist/stream/events.py +++ b/EvoScientist/stream/events.py @@ -8,6 +8,7 @@ import asyncio import base64 import mimetypes import os +import re from collections.abc import AsyncIterator from typing import Any @@ -20,6 +21,16 @@ from .emitter import StreamEventEmitter from .tracker import ToolCallTracker from .utils import DisplayLimits, is_success +# Safety net: older ccproxy versions may embed thinking as XML tags in content +# strings. Strip them so they never leak to users or channels. +_THINKING_TAG_RE = re.compile(r".*?", re.DOTALL) + + +def _strip_legacy_thinking_tags(content: str) -> str: + """Remove ``...`` tags from content strings.""" + return _THINKING_TAG_RE.sub("", content) + + # Image media types returned by DeepAgents read_file _IMAGE_MEDIA_TYPES = { "image/png", @@ -602,10 +613,7 @@ def _process_chunk_content( if isinstance(content, str): if content: - # Strip ccproxy ... tags from content - from ..llm.models import strip_thinking_tags - - cleaned = strip_thinking_tags(content) + cleaned = _strip_legacy_thinking_tags(content) if cleaned: yield emitter.text(cleaned) return @@ -645,7 +653,9 @@ def _process_chunk_content( elif block_type == "text": text = block.get("text") or block.get("content") or "" if text: - yield emitter.text(text) + text = _strip_legacy_thinking_tags(text) + if text: + yield emitter.text(text) elif block_type in ("tool_use", "tool_call"): tool_id = block.get("id", "") diff --git a/pyproject.toml b/pyproject.toml index 9316da5..238f037 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,7 +65,7 @@ wechat = ["pycryptodome>=3.20"] feishu = ["lark-oapi>=1.4.0"] qq = ["qq-botpy>=1.0"] stt = ["faster-whisper>=1.0"] -oauth = ["ccproxy-api>=0.2.4"] +oauth = ["ccproxy-api>=0.2.7"] all-channels = [ "python-telegram-bot>=21.0", "discord.py>=2.3", diff --git a/tests/test_llm.py b/tests/test_llm.py index 3949c5d..3ce8d28 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -427,7 +427,7 @@ 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" - # Proxy mode: thinking skipped for 4-6 models (ccproxy manages it) + # Proxy mode: thinking skipped (history round-trip causes 422) assert "thinking" not in call_kwargs @patch("EvoScientist.llm.models.init_chat_model") @@ -696,7 +696,7 @@ class TestAutoConfig: @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).""" + """Anthropic 4-6 models via proxy skip thinking (history round-trip 422).""" mock_init.return_value = "mock_model" monkeypatch.setenv("ANTHROPIC_BASE_URL", "http://127.0.0.1:8000") monkeypatch.setenv("ANTHROPIC_API_KEY", "ccproxy-oauth") @@ -709,7 +709,7 @@ class TestAutoConfig: @patch("EvoScientist.llm.models.init_chat_model") def test_anthropic_4_5_proxy_no_thinking(self, mock_init, monkeypatch): - """Anthropic 4-5 models via proxy also skip thinking.""" + """Anthropic 4-5 models via proxy also skip thinking (history round-trip 422).""" mock_init.return_value = "mock_model" monkeypatch.setenv("ANTHROPIC_BASE_URL", "http://127.0.0.1:8000") monkeypatch.setenv("ANTHROPIC_API_KEY", "ccproxy-oauth") @@ -767,8 +767,55 @@ class TestAutoConfig: assert call_kwargs["model_provider"] == "openai" assert call_kwargs["base_url"] == "http://127.0.0.1:8000/codex/v1" assert call_kwargs["api_key"] == "ccproxy-oauth" - # Proxy mode: reasoning skipped (triggers Responses API → rs_ 404) + # Proxy mode: reasoning skipped (Chat Completions doesn't support it) assert "reasoning" not in call_kwargs + # Proxy mode: Chat Completions + no streaming (ccproxy workarounds) + assert call_kwargs["use_responses_api"] is False + assert call_kwargs["streaming"] is False + + @patch("EvoScientist.llm.models.init_chat_model") + def test_openai_localhost_non_ccproxy_not_downgraded(self, mock_init, monkeypatch): + """Local OpenAI-compatible endpoints (vLLM, etc.) are not affected by ccproxy workarounds.""" + mock_init.return_value = "mock_model" + monkeypatch.setenv("OPENAI_BASE_URL", "http://127.0.0.1:8080/v1") + monkeypatch.setenv("OPENAI_API_KEY", "sk-local-key") + + get_chat_model("gpt-5-nano", provider="openai") + + call_kwargs = mock_init.call_args[1] + assert call_kwargs["base_url"] == "http://127.0.0.1:8080/v1" + # NOT ccproxy: reasoning should be applied, no forced Chat Completions + assert call_kwargs["reasoning"] == {"effort": "high", "summary": "auto"} + assert "use_responses_api" not in call_kwargs + assert "streaming" not in call_kwargs + + @patch("EvoScientist.llm.models.init_chat_model") + def test_openai_codex_path_but_wrong_key_not_ccproxy(self, mock_init, monkeypatch): + """ccproxy detection requires both /codex/ path AND ccproxy-oauth key.""" + mock_init.return_value = "mock_model" + monkeypatch.setenv("OPENAI_BASE_URL", "http://127.0.0.1:8000/codex/v1") + monkeypatch.setenv("OPENAI_API_KEY", "sk-real-key") + + get_chat_model("gpt-5-nano", provider="openai") + + call_kwargs = mock_init.call_args[1] + assert call_kwargs["reasoning"] == {"effort": "high", "summary": "auto"} + assert "use_responses_api" not in call_kwargs + + @patch("EvoScientist.llm.models.init_chat_model") + def test_openai_ccproxy_key_but_wrong_path_not_ccproxy( + self, mock_init, monkeypatch + ): + """ccproxy detection requires both /codex/ path AND ccproxy-oauth key.""" + mock_init.return_value = "mock_model" + monkeypatch.setenv("OPENAI_BASE_URL", "http://127.0.0.1:8000/v1") + monkeypatch.setenv("OPENAI_API_KEY", "ccproxy-oauth") + + get_chat_model("gpt-5-nano", provider="openai") + + call_kwargs = mock_init.call_args[1] + assert call_kwargs["reasoning"] == {"effort": "high", "summary": "auto"} + assert "use_responses_api" not in call_kwargs @patch("EvoScientist.llm.models.init_chat_model") def test_openai_no_base_url_when_unset(self, mock_init, monkeypatch): diff --git a/tests/test_stream_events.py b/tests/test_stream_events.py index 03d3bee..de6a4ac 100644 --- a/tests/test_stream_events.py +++ b/tests/test_stream_events.py @@ -6,7 +6,11 @@ from unittest.mock import AsyncMock, MagicMock from langchain_core.messages import AIMessageChunk -from EvoScientist.stream.events import _extract_tool_content, stream_agent_events +from EvoScientist.stream.events import ( + _extract_tool_content, + _process_chunk_content, + stream_agent_events, +) class TestExtractToolContent: @@ -91,6 +95,60 @@ class TestExtractToolContent: assert content == "some result" +# ============================================================================= +# _process_chunk_content — string content passthrough +# ============================================================================= + + +class TestProcessChunkContentStrings: + """Verify _process_chunk_content handles string content correctly. + + After removing strip_thinking_tags (ccproxy >=0.2.7 no longer embeds + tags), string content is emitted verbatim. These tests + serve as a regression baseline: if a future ccproxy version re-introduces + tags, the raw tags will be visible and these tests will document that. + """ + + def _emit(self, content: str) -> list: + from EvoScientist.stream.emitter import StreamEventEmitter + from EvoScientist.stream.tracker import ToolCallTracker + + emitter = StreamEventEmitter() + tracker = ToolCallTracker() + chunk = AIMessageChunk(content=content) + return list(_process_chunk_content(chunk, emitter, tracker)) + + def test_plain_text_passthrough(self): + events = self._emit("Hello world") + assert len(events) == 1 + assert events[0].type == "text" + assert events[0].data["content"] == "Hello world" + + def test_thinking_tags_stripped(self): + """Legacy tags from older ccproxy are stripped.""" + raw = "some reasoningThe answer is 42." + events = self._emit(raw) + assert len(events) == 1 + assert "" not in events[0].data["content"] + assert events[0].data["content"] == "The answer is 42." + + def test_thinking_tags_only_yields_nothing(self): + """Content that is only a thinking block yields no events.""" + events = self._emit("just reasoning") + assert events == [] + + def test_thinking_tags_preserve_surrounding_whitespace(self): + """Stripping tags does not swallow adjacent spaces.""" + raw = "before x after" + events = self._emit(raw) + assert len(events) == 1 + assert events[0].data["content"] == "before after" + + def test_empty_string_no_events(self): + events = self._emit("") + assert events == [] + + # ============================================================================= # Multi-mode streaming chunk unpacking # ============================================================================= diff --git a/uv.lock b/uv.lock index 508d245..22a8a27 100644 --- a/uv.lock +++ b/uv.lock @@ -350,7 +350,7 @@ wheels = [ [[package]] name = "ccproxy-api" -version = "0.2.6" +version = "0.2.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiofiles" }, @@ -368,9 +368,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "uvicorn" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/29/ea/3b277688847ed229f24ff32875597cf215176f52a9fcea7a0dafde380ce9/ccproxy_api-0.2.6.tar.gz", hash = "sha256:9fea2c59c632db653b7254ee3c3833e716665598d1f9b1f7d1af3c8c79728648", size = 680952, upload-time = "2026-03-22T20:44:21.05Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/f2/89247f1d4fa31ddccf271db6d300ae9bffb66c7f7e804539a7099995a255/ccproxy_api-0.2.7.tar.gz", hash = "sha256:35af27665fadab4a49396604531ff81acb7c648c3f8657aa96086e92eebdbf08", size = 681504, upload-time = "2026-03-31T08:20:01.629Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/d3/ff6613b59db9ae1587905d3d631555e5eee116b0cca8ad8cff5058397541/ccproxy_api-0.2.6-py3-none-any.whl", hash = "sha256:4333ae6f2c5b39b8ca15c20624725e34295651ee810d476233b73bf6fba7e49e", size = 877705, upload-time = "2026-03-22T20:44:18.942Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f9/854c5f8e178e8eb953e5ab737c7f148fc175ba946bf9618a1b5181d435f8/ccproxy_api-0.2.7-py3-none-any.whl", hash = "sha256:961f972131aa2b30a45a37fcf8459ce7857e73728cf22a7967a9ae6fb60eb82d", size = 878222, upload-time = "2026-03-31T08:20:00.011Z" }, ] [[package]] @@ -957,7 +957,7 @@ requires-dist = [ { name = "aiohttp", marker = "extra == 'all-channels'", specifier = ">=3.9" }, { name = "aiohttp", marker = "extra == 'slack'", specifier = ">=3.9" }, { name = "build", marker = "extra == 'dev'", specifier = ">=1.0" }, - { name = "ccproxy-api", marker = "extra == 'oauth'", specifier = ">=0.2.4" }, + { name = "ccproxy-api", marker = "extra == 'oauth'", specifier = ">=0.2.7" }, { name = "deepagents", specifier = ">=0.4.11" }, { name = "discord-py", marker = "extra == 'all-channels'", specifier = ">=2.3" }, { name = "discord-py", marker = "extra == 'discord'", specifier = ">=2.3" },