fix: stabilize DeepSeek streaming output

This commit is contained in:
avir4er 2026-07-03 11:12:07 +08:00
parent 1c79507140
commit c78b489edb
5 changed files with 151 additions and 19 deletions

View File

@ -68,7 +68,6 @@ async def fetch_response_litellm_openai(
"""
try:
if stream:
ret = await litellm.acompletion(**kwargs)
stream_obj = await litellm.acompletion(**kwargs)
return _build_response_obj(model_name, model_settings, tool_choice, parallel_tool_calls), stream_obj
else:
@ -102,7 +101,6 @@ async def fetch_response_litellm_openai(
kwargs["messages"] = messages
if stream:
ret = await litellm.acompletion(**kwargs)
stream_obj = await litellm.acompletion(**kwargs)
return _build_response_obj(model_name, model_settings, tool_choice, parallel_tool_calls), stream_obj
else:

View File

@ -1994,10 +1994,7 @@ class OpenAIChatCompletionsModel(Model):
# Check if model supports reasoning (Claude or DeepSeek)
model_str_lower = str(self.model).lower()
if (
detect_claude_thinking_in_stream(str(self.model))
or "deepseek" in model_str_lower
):
if detect_claude_thinking_in_stream(str(self.model)):
print_claude_reasoning_simple(
reasoning_content, self.agent_name, str(self.model)
)

View File

@ -4208,7 +4208,7 @@ def create_claude_thinking_context(agent_name, counter, model):
context = {
"thinking_id": thinking_id,
"live": live,
"panel": panel,
"panel": None,
"header": header,
"thinking_content": thinking_content,
"timestamp": timestamp,
@ -4358,10 +4358,29 @@ def finish_claude_thinking_display(context):
return False
def _raw_reasoning_display_enabled() -> bool:
"""Return whether raw provider reasoning text should be displayed.
DeepSeek-compatible gateways often stream ``reasoning_content`` in tiny
deltas. Rendering those deltas by default floods the terminal and can leak
model-internal scratch text. Keep it opt-in for DeepSeek via
``CAI_SHOW_REASONING=true`` (or ``CAI_SHOW_THINKING=true`` for older local
configs).
"""
raw = os.getenv("CAI_SHOW_REASONING")
if raw is None:
raw = os.getenv("CAI_SHOW_THINKING")
if raw is None:
return False
return raw.strip().lower() in ("1", "true", "yes", "on")
def detect_claude_thinking_in_stream(model_name):
"""
Detect if a model should show thinking/reasoning display.
Applies to Claude and DeepSeek models with reasoning capability.
Claude keeps the historical default. DeepSeek raw reasoning is opt-in
because providers commonly stream it token-by-token as ``reasoning_content``.
Args:
model_name: The model name to check
@ -4389,17 +4408,10 @@ def detect_claude_thinking_in_stream(model_name):
or "thinking" in model_str
)
# Check for DeepSeek models with reasoning capability
has_deepseek_reasoning = "deepseek" in model_str and (
# DeepSeek reasoner models
"reasoner" in model_str
or
# DeepSeek chat models also support reasoning
"chat" in model_str
or
# Generic deepseek models likely support it
"/" in model_str # e.g., deepseek/deepseek-chat
)
# DeepSeek reasoning display is intentionally opt-in. The text is still
# accumulated internally by the model stream handler so empty-response
# detection and accounting keep working; it is just not printed by default.
has_deepseek_reasoning = "deepseek" in model_str and _raw_reasoning_display_enabled()
return has_claude_reasoning or has_deepseek_reasoning

View File

@ -0,0 +1,86 @@
import pytest
from openai import NOT_GIVEN
from cai.sdk.agents.model_settings import ModelSettings
from cai.sdk.agents.models.chatcompletions.litellm_adapter import (
fetch_response_litellm_openai,
)
@pytest.mark.asyncio
async def test_litellm_streaming_fetch_opens_one_completion(monkeypatch):
calls = []
sentinel_stream = object()
async def fake_acompletion(**kwargs):
calls.append(kwargs.copy())
return sentinel_stream
monkeypatch.setattr(
"cai.sdk.agents.models.chatcompletions.litellm_adapter.litellm.acompletion",
fake_acompletion,
)
response, stream = await fetch_response_litellm_openai(
kwargs={"model": "deepseek/deepseek-v4-pro", "messages": [], "stream": True},
model_name="deepseek/deepseek-v4-pro",
model_settings=ModelSettings(),
tool_choice=NOT_GIVEN,
stream=True,
parallel_tool_calls=False,
)
assert stream is sentinel_stream
assert response.model == "deepseek/deepseek-v4-pro"
assert len(calls) == 1
assert calls[0]["stream"] is True
@pytest.mark.asyncio
async def test_litellm_streaming_tool_call_id_retry_opens_one_retry_stream(monkeypatch):
calls = []
sentinel_stream = object()
async def fake_acompletion(**kwargs):
calls.append(kwargs.copy())
if len(calls) == 1:
raise Exception("Invalid 'messages': tool_call_id string too long maximum length")
return sentinel_stream
monkeypatch.setattr(
"cai.sdk.agents.models.chatcompletions.litellm_adapter.litellm.acompletion",
fake_acompletion,
)
long_id = "call_" + "x" * 80
kwargs = {
"model": "deepseek/deepseek-v4-pro",
"messages": [
{"role": "tool", "tool_call_id": long_id, "content": "ok"},
{
"role": "assistant",
"tool_calls": [
{
"id": long_id,
"type": "function",
"function": {"name": "probe", "arguments": "{}"},
}
],
},
],
"stream": True,
}
_response, stream = await fetch_response_litellm_openai(
kwargs=kwargs,
model_name="deepseek/deepseek-v4-pro",
model_settings=ModelSettings(),
tool_choice=NOT_GIVEN,
stream=True,
parallel_tool_calls=False,
)
assert stream is sentinel_stream
assert len(calls) == 2
assert kwargs["messages"][0]["tool_call_id"] == long_id[:40]
assert kwargs["messages"][1]["tool_calls"][0]["id"] == long_id[:40]

View File

@ -0,0 +1,39 @@
from cai.util import streaming
def test_create_claude_thinking_context_for_deepseek_does_not_reference_missing_panel(capsys):
streaming._CLAUDE_THINKING_PANELS.clear()
context = streaming.create_claude_thinking_context(
"Web App Pentester",
1,
"deepseek/deepseek-v4-pro",
)
captured = capsys.readouterr()
assert context is not None
assert "Error creating DeepSeek thinking context" not in captured.out
assert context["model_display"] == "DeepSeek"
assert context["accumulated_thinking"] == ""
assert context["is_started"] is False
thinking_id = context["thinking_id"]
assert streaming._CLAUDE_THINKING_PANELS[thinking_id] is context
streaming._CLAUDE_THINKING_PANELS.clear()
def test_deepseek_reasoning_display_is_opt_in(monkeypatch):
monkeypatch.delenv("CAI_SHOW_REASONING", raising=False)
monkeypatch.delenv("CAI_SHOW_THINKING", raising=False)
assert streaming.detect_claude_thinking_in_stream("deepseek/deepseek-v4-pro") is False
monkeypatch.setenv("CAI_SHOW_REASONING", "true")
assert streaming.detect_claude_thinking_in_stream("deepseek/deepseek-v4-pro") is True
def test_claude_reasoning_display_keeps_historical_default(monkeypatch):
monkeypatch.delenv("CAI_SHOW_REASONING", raising=False)
monkeypatch.delenv("CAI_SHOW_THINKING", raising=False)
assert streaming.detect_claude_thinking_in_stream("claude-sonnet-4-20250514") is True