feat: add chat content compatibility for list-shaped responses

This commit is contained in:
ummon-v 2026-09-23 09:11:53 +02:00
parent 56f7d45388
commit 1a84e6e228
No known key found for this signature in database
2 changed files with 98 additions and 1 deletions

View file

@ -40,7 +40,7 @@ from strix.config import codex
from strix.config.loader import load_settings
from strix.config.tool_call_ids import TurnCallIdRewriter, dedupe_input
from strix.config.tool_call_limits import TurnToolCallLimiter
from strix.llm import request_log
from strix.llm import chat_content_compat, request_log
if TYPE_CHECKING:
@ -664,6 +664,7 @@ def configure_sdk_model_defaults(settings: Settings) -> None:
llm = settings.llm
set_tracing_disabled(True)
request_log.install()
chat_content_compat.install()
if codex.subscription_model(llm.model):
return
_configure_litellm_compatibility()

View file

@ -0,0 +1,96 @@
"""Flatten list-shaped chat-completions content from OpenAI-compatible providers.
Reasoning models behind OpenAI-compatible endpoints sometimes return the
assistant content as a list of content blocks (for example
``[{"type": "thinking", ...}, {"type": "text", "text": "OK"}]``) instead of a
plain string.
``install()`` wraps the two SDK entry points that consume chat-completions
content, flattening list-shaped ``message.content`` and ``delta.content`` into
the concatenated ``text`` blocks before the SDK sees them. Non-text blocks
(e.g. ``thinking``) and unknown block types are ignored.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any, cast
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from collections.abc import AsyncIterator
def _flatten_list_content(content: object) -> Any:
"""Concatenate the ``text`` blocks of list-shaped content; pass anything else through."""
if not isinstance(content, list):
return content
texts: list[str] = []
blocks = cast("list[Any]", content) # type: ignore[redundant-cast]
for part in blocks:
if not isinstance(part, dict):
continue
block = cast("dict[str, Any]", part)
if block.get("type") == "text":
text = block.get("text")
if isinstance(text, str):
texts.append(text)
return "".join(texts)
async def _flatten_chunk_stream(stream: AsyncIterator[Any]) -> AsyncIterator[Any]:
async for chunk in stream:
choices: list[Any] = getattr(chunk, "choices", None) or []
for choice in choices:
delta: Any = getattr(choice, "delta", None)
content = getattr(delta, "content", None)
if delta is not None and isinstance(content, list):
blocks = cast("list[Any]", content) # type: ignore[redundant-cast]
choice.delta = delta.model_copy(update={"content": _flatten_list_content(blocks)})
yield chunk
_installed = False
def install() -> None:
"""Wrap the SDK chat-completions content entry points (idempotent)."""
global _installed # noqa: PLW0603
if _installed:
return
_installed = True
try:
_install_wrappers()
except Exception: # noqa: BLE001 - an SDK layout change degrades to the stock behavior
logger.warning("could not wrap the SDK chat-completions content handling", exc_info=True)
def _install_wrappers() -> None:
from agents.models import chatcmpl_converter
from agents.models.chatcmpl_stream_handler import ChatCmplStreamHandler
# 0.19.0 names it Converter; later 0.19.x releases renamed it ChatCmplConverter.
converter_cls = getattr(chatcmpl_converter, "ChatCmplConverter", None) or (
chatcmpl_converter.Converter
)
convert = converter_cls.__dict__["message_to_output_items"].__func__
handle_stream = ChatCmplStreamHandler.__dict__["handle_stream"].__func__
def message_to_output_items(cls: Any, message: Any, *args: Any, **kwargs: Any) -> Any:
content = getattr(message, "content", None)
if isinstance(content, list):
blocks = cast("list[Any]", content) # type: ignore[redundant-cast]
message = message.model_copy(update={"content": _flatten_list_content(blocks)})
return convert(cls, message, *args, **kwargs)
def handle_list_content_stream(
cls: Any, response: Any, stream: AsyncIterator[Any], *args: Any, **kwargs: Any
) -> Any:
return handle_stream(cls, response, _flatten_chunk_stream(stream), *args, **kwargs)
converter_cls.message_to_output_items = classmethod(message_to_output_items) # type: ignore[method-assign]
ChatCmplStreamHandler.handle_stream = classmethod(handle_list_content_stream) # type: ignore[assignment]
_installed = True