From d5b982c980545189f92971eaf6b4b3b57756c234 Mon Sep 17 00:00:00 2001 From: Xi Zhang <106144707+X-iZhang@users.noreply.github.com> Date: Thu, 9 Apr 2026 12:51:05 +0200 Subject: [PATCH] =?UTF-8?q?fix(ccproxy):=20update=20Responses=20API=20hand?= =?UTF-8?q?ling=20and=20patch=20system=20role=20con=E2=80=A6=20(#149)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ccproxy): update Responses API handling and patch system role conversion * fix(ccproxy): streamline _agenerate method in system to developer patch * fix(ccproxy): improve handling of None output in Codex compatibility patch --- EvoScientist/llm/models.py | 23 +++-- EvoScientist/llm/patches.py | 191 ++++++++++++++++++++++++++++++++++++ tests/test_llm.py | 8 +- 3 files changed, 210 insertions(+), 12 deletions(-) diff --git a/EvoScientist/llm/models.py b/EvoScientist/llm/models.py index 4e1b208..9cc1248 100644 --- a/EvoScientist/llm/models.py +++ b/EvoScientist/llm/models.py @@ -16,6 +16,7 @@ from langchain.chat_models import init_chat_model from .patches import ( _is_ccproxy_codex, + _patch_ccproxy_system_to_developer, _patch_openai_compat_content, _patch_openrouter_reasoning_details, ) @@ -321,14 +322,17 @@ def get_chat_model( kwargs["base_url"] = base_url _is_openai_proxy = _is_ccproxy_codex() if _is_openai_proxy: - # 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) + # Use Responses API for ccproxy: bypasses the format chain + # converter (Chat→Responses→Chat) which returns 502 on + # complex responses. System messages are converted to + # developer role by _patch_ccproxy_system_to_developer(). + kwargs.setdefault("use_responses_api", True) + # Streaming must stay ON for Responses API: ccproxy's + # StreamingBufferService loses output when assembling + # non-streaming responses. (The old streaming=False was + # for Chat Completions tool_call duplication — not an issue + # with the Responses API SSE format.) + kwargs.pop("streaming", None) # remove if set elsewhere api_key = os.environ.get("OPENAI_API_KEY", "") if api_key: kwargs["api_key"] = api_key @@ -432,6 +436,9 @@ def get_chat_model( ) and _original_provider not in _no_patch_providers: _patch_openai_compat_content(chat_model) + if _is_openai_proxy: + _patch_ccproxy_system_to_developer(chat_model) + return chat_model diff --git a/EvoScientist/llm/patches.py b/EvoScientist/llm/patches.py index 0215037..1bf7acf 100644 --- a/EvoScientist/llm/patches.py +++ b/EvoScientist/llm/patches.py @@ -7,6 +7,8 @@ Patches: - _patch_anthropic_proxy_compat: ccproxy dict→Pydantic model mismatch - _patch_openrouter_reasoning_details: reasoning_details schema errors - _patch_openai_compat_content: list content→string for strict APIs + - _patch_ccproxy_codex_compat: ccproxy model fixes + langchain None guard + - _patch_ccproxy_system_to_developer: system→developer role for ccproxy Utilities: - _is_ccproxy_codex: detect ccproxy Codex OAuth adapter @@ -59,6 +61,112 @@ def _patch_anthropic_proxy_compat() -> None: _patch_anthropic_proxy_compat() +# --------------------------------------------------------------------------- +# Patch: ccproxy-api 0.2.7 Codex compatibility. +# +# 1) ResponseObject.output is required but upstream may omit it → 502. +# Fix: make output default to []. +# 2) CodexMessage.role only allows "user"/"assistant" → 400 on system msgs. +# Fix: widen to also accept "system" and "developer". +# 3) langchain-openai iterates response.output which can be None after the +# proxy strips it. Fix: guard in _construct_lc_result_from_responses_api. +# --------------------------------------------------------------------------- +def _patch_ccproxy_codex_compat() -> None: + """Patch ccproxy-api models for Responses API compatibility.""" + # 1) Make ResponseObject.output optional (default=[]) + try: + import ccproxy.llms.models.openai as _oai_mod + + _OrigResponse = _oai_mod.ResponseObject + + from pydantic import Field as _PydanticField + + class _PatchedResponseObject(_OrigResponse): # type: ignore[misc] + output: list = _PydanticField(default_factory=list) # type: ignore[assignment] + + model_config = _OrigResponse.model_config.copy() + + _PatchedResponseObject.__name__ = "ResponseObject" + _PatchedResponseObject.__qualname__ = "ResponseObject" + _oai_mod.ResponseObject = _PatchedResponseObject # type: ignore[misc] + + # Also patch modules that import ResponseObject directly + for _mod_path in ( + "ccproxy.llms.formatters.openai_to_openai.responses", + "ccproxy.llms.formatters.anthropic_to_openai.responses", + ): + try: + import importlib + + _mod = importlib.import_module(_mod_path) + if hasattr(_mod, "ResponseObject"): + _mod.ResponseObject = _PatchedResponseObject # type: ignore[attr-defined] + except Exception: + pass + except Exception: + pass + + # 2) Widen CodexMessage.role to accept system/developer + try: + from typing import Annotated, Literal + + import ccproxy.plugins.codex.models as _codex_mod + + _OrigMessage = _codex_mod.CodexMessage + + from pydantic import Field as _Field + + class _PatchedCodexMessage(_OrigMessage): # type: ignore[misc] + role: Annotated[ # type: ignore[assignment] + Literal["user", "assistant", "system", "developer"], + _Field(description="Message role"), + ] + + _PatchedCodexMessage.__name__ = "CodexMessage" + _PatchedCodexMessage.__qualname__ = "CodexMessage" + _codex_mod.CodexMessage = _PatchedCodexMessage # type: ignore[misc] + except Exception: + pass + + # 3) Fix StreamingBufferService returning response.completed event + # whose output is None/empty, instead of using accumulated outputs. + try: + from ccproxy.llms.streaming.accumulators import ResponsesAccumulator + + _orig_get = ResponsesAccumulator.get_completed_response + + def _patched_get(self: Any) -> dict | None: + result = _orig_get(self) + if result is not None: + output = result.get("output") + if output is None: + # output field lost — force rebuild from accumulated items + return None + return result + + ResponsesAccumulator.get_completed_response = _patched_get # type: ignore[assignment] + except Exception: + pass + + # 4) Guard langchain-openai against None output (final safety net) + try: + import langchain_openai.chat_models.base as _base + + _orig_construct = _base._construct_lc_result_from_responses_api + + def _safe(response: Any, *args: Any, **kwargs: Any) -> Any: + if response.output is None: + response.output = [] + return _orig_construct(response, *args, **kwargs) + + _base._construct_lc_result_from_responses_api = _safe + except Exception: + pass + + +_patch_ccproxy_codex_compat() + + # --------------------------------------------------------------------------- # Patch: langchain-openrouter v0.2.1 — _convert_message_to_dict() serializes # reasoning_details back to the API, but streaming chunks use wrong field @@ -217,3 +325,86 @@ def _patch_openai_compat_content(model: Any) -> None: yield chunk model._astream = _patched_astream + + +# --------------------------------------------------------------------------- +# Patch: ccproxy Codex Responses API rejects "system" role messages. +# Convert SystemMessage to use "developer" role via langchain-openai's +# __openai_role__ mechanism. +# --------------------------------------------------------------------------- +def _patch_ccproxy_system_to_developer(model: Any) -> None: + """Convert SystemMessage role from 'system' to 'developer' for ccproxy. + + ccproxy's Responses API endpoint rejects system role messages with + 400 "System messages are not allowed". LangChain's ``langchain_openai`` + checks ``additional_kwargs["__openai_role__"]`` and uses that value as + the message role when serializing to the API. + + Args: + model: A LangChain chat model instance to patch in-place. + """ + import copy + import functools + + from langchain_core.messages import BaseMessage, SystemMessage + + def _system_to_developer(messages: list[BaseMessage]) -> list[BaseMessage]: + out: list[BaseMessage] = [] + for msg in messages: + if isinstance(msg, SystemMessage): + if msg.additional_kwargs.get("__openai_role__") != "developer": + msg = copy.copy(msg) + msg.additional_kwargs = { + **msg.additional_kwargs, + "__openai_role__": "developer", + } + out.append(msg) + return out + + orig_generate = getattr(model, "_generate", None) + if orig_generate is None: + return + + @functools.wraps(orig_generate) + def _patched_generate( + messages: list[BaseMessage], *args: Any, **kwargs: Any + ) -> Any: + return orig_generate(_system_to_developer(messages), *args, **kwargs) + + model._generate = _patched_generate + + orig_agenerate = getattr(model, "_agenerate", None) + if orig_agenerate is not None: + + @functools.wraps(orig_agenerate) + async def _patched_agenerate( + messages: list[BaseMessage], *args: Any, **kwargs: Any + ) -> Any: + return await orig_agenerate(_system_to_developer(messages), *args, **kwargs) + + model._agenerate = _patched_agenerate + + orig_stream = getattr(model, "_stream", None) + if orig_stream is not None: + + @functools.wraps(orig_stream) + def _patched_stream( + messages: list[BaseMessage], *args: Any, **kwargs: Any + ) -> Any: + return orig_stream(_system_to_developer(messages), *args, **kwargs) + + model._stream = _patched_stream + + orig_astream = getattr(model, "_astream", None) + if orig_astream is not None: + + @functools.wraps(orig_astream) + async def _patched_astream( + messages: list[BaseMessage], *args: Any, **kwargs: Any + ) -> Any: + async for chunk in orig_astream( + _system_to_developer(messages), *args, **kwargs + ): + yield chunk + + model._astream = _patched_astream diff --git a/tests/test_llm.py b/tests/test_llm.py index e9bb567..c5e7425 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -914,11 +914,11 @@ 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 (Chat Completions doesn't support it) + # Proxy mode: reasoning skipped (ccproxy untested) 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 + # Proxy mode: Responses API (bypasses format chain), streaming ON + assert call_kwargs["use_responses_api"] is True + assert "streaming" not in call_kwargs @patch("EvoScientist.llm.models.init_chat_model") def test_openai_localhost_non_ccproxy_not_downgraded(self, mock_init, monkeypatch):