diff --git a/src/cai/sdk/agents/models/chatcompletions/litellm_adapter.py b/src/cai/sdk/agents/models/chatcompletions/litellm_adapter.py index 795d65a0..8533bb68 100644 --- a/src/cai/sdk/agents/models/chatcompletions/litellm_adapter.py +++ b/src/cai/sdk/agents/models/chatcompletions/litellm_adapter.py @@ -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: diff --git a/src/cai/sdk/agents/models/openai_chatcompletions.py b/src/cai/sdk/agents/models/openai_chatcompletions.py index 47d871b0..abd933b6 100644 --- a/src/cai/sdk/agents/models/openai_chatcompletions.py +++ b/src/cai/sdk/agents/models/openai_chatcompletions.py @@ -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) ) diff --git a/src/cai/util/streaming.py b/src/cai/util/streaming.py index ead368db..f37065b8 100644 --- a/src/cai/util/streaming.py +++ b/src/cai/util/streaming.py @@ -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 diff --git a/tests/sdk/test_litellm_adapter_streaming.py b/tests/sdk/test_litellm_adapter_streaming.py new file mode 100644 index 00000000..7c05dde4 --- /dev/null +++ b/tests/sdk/test_litellm_adapter_streaming.py @@ -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] diff --git a/tests/util/test_thinking_display.py b/tests/util/test_thinking_display.py new file mode 100644 index 00000000..ea206558 --- /dev/null +++ b/tests/util/test_thinking_display.py @@ -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