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" },