diff --git a/backend/utils/llm_utils.py b/backend/utils/llm_utils.py index d1e461f1f..8b768d70d 100644 --- a/backend/utils/llm_utils.py +++ b/backend/utils/llm_utils.py @@ -157,8 +157,10 @@ def call_llm_for_system_prompt( if delta is None: logger.debug("Skipping LLM stream chunk without delta") continue - - reasoning_content = getattr(delta, "reasoning_content", None) + + reasoning_content = getattr(delta, "reasoning", None) + if reasoning_content is None: + reasoning_content = getattr(delta, "reasoning_content", None) new_token = getattr(delta, "content", None) # Note: reasoning_content is separate metadata and doesn't affect content filtering diff --git a/sdk/nexent/core/models/openai_llm.py b/sdk/nexent/core/models/openai_llm.py index 2cfa7c8db..678df1d9f 100644 --- a/sdk/nexent/core/models/openai_llm.py +++ b/sdk/nexent/core/models/openai_llm.py @@ -391,9 +391,10 @@ def __call__(self, messages: List[Dict[str, Any]], stop_sequences: Optional[List if chunk_finish_reason is not None: finish_reason = str(chunk_finish_reason) - new_token = chunk.choices[0].delta.content - reasoning_content = getattr( - chunk.choices[0].delta, 'reasoning_content', None) + new_token = getattr(chunk.choices[0].delta, "content", None) + reasoning_content = getattr(chunk.choices[0].delta, "reasoning", None) + if reasoning_content is None: + reasoning_content = getattr(chunk.choices[0].delta, "reasoning_content", None) # Handle reasoning_content if it exists and is not null if reasoning_content is not None: diff --git a/test/backend/utils/test_llm_utils.py b/test/backend/utils/test_llm_utils.py index 06d99db95..e21f8543e 100644 --- a/test/backend/utils/test_llm_utils.py +++ b/test/backend/utils/test_llm_utils.py @@ -594,6 +594,7 @@ def mock_callback(text): def test_call_llm_for_system_prompt_with_reasoning_content(self, mocker: MockFixture): """Test call_llm_for_system_prompt with reasoning_content""" + mock_logger = mocker.patch('backend.utils.llm_utils.logger') mock_get_model_by_id = mocker.patch('backend.utils.llm_utils.get_model_by_model_id') mock_adapter = mocker.patch('backend.utils.llm_utils.get_llm_adapter_from_config') @@ -603,6 +604,7 @@ def test_call_llm_for_system_prompt_with_reasoning_content(self, mocker: MockFix mock_chunk = MagicMock() mock_chunk.choices = [MagicMock()] mock_chunk.choices[0].delta.content = "Generated prompt" + mock_chunk.choices[0].delta.reasoning = None mock_chunk.choices[0].delta.reasoning_content = "Some reasoning" mock_llm_instance.client = MagicMock() @@ -616,6 +618,39 @@ def test_call_llm_for_system_prompt_with_reasoning_content(self, mocker: MockFix ) assert result == "Generated prompt" + mock_logger.debug.assert_any_call( + "Received reasoning_content (metadata only, not filtering content)" + ) + + def test_call_llm_for_system_prompt_with_reasoning(self, mocker: MockFixture): + """Test call_llm_for_system_prompt with the alternate reasoning field.""" + mock_logger = mocker.patch('backend.utils.llm_utils.logger') + mock_get_model_by_id = mocker.patch('backend.utils.llm_utils.get_model_by_model_id') + mock_adapter = mocker.patch('backend.utils.llm_utils.get_llm_adapter_from_config') + + mock_get_model_by_id.return_value = {"base_url": "http://example.com", "api_key": "fake-key"} + + mock_llm_instance = mock_adapter.return_value + mock_chunk = MagicMock() + mock_chunk.choices = [MagicMock()] + mock_chunk.choices[0].delta.content = "Generated prompt" + mock_chunk.choices[0].delta.reasoning = "Some reasoning" + mock_chunk.choices[0].delta.reasoning_content = None + + mock_llm_instance.client = MagicMock() + mock_llm_instance.client.chat.completions.create.return_value = [mock_chunk] + mock_llm_instance._prepare_completion_kwargs.return_value = {} + + result = call_llm_for_system_prompt( + 1, + "user prompt", + "system prompt", + ) + + assert result == "Generated prompt" + mock_logger.debug.assert_any_call( + "Received reasoning_content (metadata only, not filtering content)" + ) def test_call_llm_for_system_prompt_multiple_chunks(self, mocker: MockFixture): """Test call_llm_for_system_prompt with multiple chunks""" diff --git a/test/sdk/core/models/test_openai_llm.py b/test/sdk/core/models/test_openai_llm.py index df1b91a5d..3d5a5faab 100644 --- a/test/sdk/core/models/test_openai_llm.py +++ b/test/sdk/core/models/test_openai_llm.py @@ -790,12 +790,14 @@ def test_call_with_reasoning_content(openai_model_instance): mock_chunk1.choices = [MagicMock()] mock_chunk1.choices[0].delta.content = "Let me think about this" mock_chunk1.choices[0].delta.role = "assistant" + mock_chunk1.choices[0].delta.reasoning = None mock_chunk1.choices[0].delta.reasoning_content = "This is a reasoning step" mock_chunk2 = MagicMock() mock_chunk2.choices = [MagicMock()] mock_chunk2.choices[0].delta.content = "Response" mock_chunk2.choices[0].delta.role = None + mock_chunk2.choices[0].delta.reasoning = None mock_chunk2.choices[0].delta.reasoning_content = None mock_chunk2.usage = MagicMock() mock_chunk2.usage.prompt_tokens = 5 @@ -828,6 +830,36 @@ def test_call_with_reasoning_content(openai_model_instance): "Response") +def test_call_with_reasoning_field(openai_model_instance): + """Test __call__ handles providers that stream reasoning in the reasoning field.""" + messages = [{"role": "user", "content": [{"text": "Hello"}]}] + + mock_chunk = MagicMock() + mock_chunk.choices = [MagicMock()] + mock_chunk.choices[0].delta.content = "Response" + mock_chunk.choices[0].delta.role = "assistant" + mock_chunk.choices[0].delta.reasoning = "Alternate reasoning field" + mock_chunk.usage = MagicMock() + mock_chunk.usage.prompt_tokens = 5 + mock_chunk.usage.total_tokens = 8 + + mock_result_message = MagicMock() + mock_result_message.raw = [mock_chunk] + mock_result_message.role = MagicMock() + + with patch.object(openai_model_instance, "_prepare_completion_kwargs", return_value={}), \ + patch.object(mock_models_module.ChatMessage, "from_dict", return_value=mock_result_message): + openai_model_instance.client.chat.completions.create.return_value = [mock_chunk] + + result = openai_model_instance.__call__(messages) + + assert result == mock_result_message + openai_model_instance.observer.add_model_reasoning_content.assert_called_once_with( + "Alternate reasoning field" + ) + openai_model_instance.observer.add_model_new_token.assert_called_once_with("Response") + + def test_call_with_multiple_reasoning_content_chunks(openai_model_instance): """Test __call__ method handles multiple chunks with reasoning_content""" @@ -838,18 +870,21 @@ def test_call_with_multiple_reasoning_content_chunks(openai_model_instance): mock_chunk1.choices = [MagicMock()] mock_chunk1.choices[0].delta.content = "Let me" mock_chunk1.choices[0].delta.role = "assistant" + mock_chunk1.choices[0].delta.reasoning = None mock_chunk1.choices[0].delta.reasoning_content = "First reasoning step" mock_chunk2 = MagicMock() mock_chunk2.choices = [MagicMock()] mock_chunk2.choices[0].delta.content = " think" mock_chunk2.choices[0].delta.role = None + mock_chunk2.choices[0].delta.reasoning = None mock_chunk2.choices[0].delta.reasoning_content = "Second reasoning step" mock_chunk3 = MagicMock() mock_chunk3.choices = [MagicMock()] mock_chunk3.choices[0].delta.content = " about this" mock_chunk3.choices[0].delta.role = None + mock_chunk3.choices[0].delta.reasoning = None mock_chunk3.choices[0].delta.reasoning_content = None mock_chunk3.usage = MagicMock() mock_chunk3.usage.prompt_tokens = 5 @@ -896,12 +931,14 @@ def test_call_with_reasoning_content_only(openai_model_instance): mock_chunk1.choices = [MagicMock()] mock_chunk1.choices[0].delta.content = None mock_chunk1.choices[0].delta.role = "assistant" + mock_chunk1.choices[0].delta.reasoning = None mock_chunk1.choices[0].delta.reasoning_content = "Pure reasoning content" mock_chunk2 = MagicMock() mock_chunk2.choices = [MagicMock()] mock_chunk2.choices[0].delta.content = "Final response" mock_chunk2.choices[0].delta.role = None + mock_chunk2.choices[0].delta.reasoning = None mock_chunk2.choices[0].delta.reasoning_content = None mock_chunk2.usage = MagicMock() mock_chunk2.usage.prompt_tokens = 5 @@ -942,6 +979,7 @@ def test_call_rejects_reasoning_only_response_and_records_diagnostics( reasoning_chunk.choices = [MagicMock()] reasoning_chunk.choices[0].delta.content = None reasoning_chunk.choices[0].delta.role = "assistant" + reasoning_chunk.choices[0].delta.reasoning = None reasoning_chunk.choices[0].delta.reasoning_content = "Internal reasoning" reasoning_chunk.choices[0].finish_reason = None reasoning_chunk.usage = None @@ -950,6 +988,7 @@ def test_call_rejects_reasoning_only_response_and_records_diagnostics( final_chunk.choices = [MagicMock()] final_chunk.choices[0].delta.content = None final_chunk.choices[0].delta.role = None + final_chunk.choices[0].delta.reasoning = None final_chunk.choices[0].delta.reasoning_content = None final_chunk.choices[0].finish_reason = "length" final_chunk.usage = MagicMock(prompt_tokens=10, completion_tokens=20) @@ -985,6 +1024,7 @@ def test_call_with_reasoning_content_and_content_together(openai_model_instance) mock_chunk.choices = [MagicMock()] mock_chunk.choices[0].delta.content = "Response text" mock_chunk.choices[0].delta.role = "assistant" + mock_chunk.choices[0].delta.reasoning = None mock_chunk.choices[0].delta.reasoning_content = "Reasoning alongside content" mock_chunk.usage = MagicMock() mock_chunk.usage.prompt_tokens = 5 @@ -1067,18 +1107,21 @@ def test_call_with_monitoring_and_token_tracker(openai_model_instance): mock_chunk1.choices = [MagicMock()] mock_chunk1.choices[0].delta.content = "Hello" mock_chunk1.choices[0].delta.role = "assistant" + mock_chunk1.choices[0].delta.reasoning = None mock_chunk1.choices[0].delta.reasoning_content = None mock_chunk2 = MagicMock() mock_chunk2.choices = [MagicMock()] mock_chunk2.choices[0].delta.content = " world" mock_chunk2.choices[0].delta.role = None + mock_chunk2.choices[0].delta.reasoning = None mock_chunk2.choices[0].delta.reasoning_content = None mock_chunk3 = MagicMock() mock_chunk3.choices = [MagicMock()] mock_chunk3.choices[0].delta.content = None mock_chunk3.choices[0].delta.role = None + mock_chunk3.choices[0].delta.reasoning = None mock_chunk3.choices[0].delta.reasoning_content = None mock_chunk3.usage = MagicMock() mock_chunk3.usage.prompt_tokens = 10 @@ -1126,12 +1169,14 @@ def test_call_with_token_tracker_on_reasoning_content(openai_model_instance): mock_chunk1.choices = [MagicMock()] mock_chunk1.choices[0].delta.content = None mock_chunk1.choices[0].delta.role = "assistant" + mock_chunk1.choices[0].delta.reasoning = None mock_chunk1.choices[0].delta.reasoning_content = "Thinking..." mock_chunk2 = MagicMock() mock_chunk2.choices = [MagicMock()] mock_chunk2.choices[0].delta.content = "Response" mock_chunk2.choices[0].delta.role = None + mock_chunk2.choices[0].delta.reasoning = None mock_chunk2.choices[0].delta.reasoning_content = None mock_chunk2.usage = MagicMock() mock_chunk2.usage.prompt_tokens = 5 @@ -1170,6 +1215,7 @@ def test_call_with_stop_event_and_token_tracker(openai_model_instance): mock_chunk.choices = [MagicMock()] mock_chunk.choices[0].delta.content = "Response" mock_chunk.choices[0].delta.role = "assistant" + mock_chunk.choices[0].delta.reasoning = None mock_chunk.choices[0].delta.reasoning_content = None with patch.object(openai_model_instance, "_prepare_completion_kwargs", return_value={}): @@ -1753,6 +1799,7 @@ def test_call_token_estimation_with_list_content(openai_model_instance): mock_chunk.choices = [MagicMock()] mock_chunk.choices[0].delta.content = "Response" mock_chunk.choices[0].delta.role = "assistant" + mock_chunk.choices[0].delta.reasoning = None mock_chunk.choices[0].delta.reasoning_content = None mock_chunk.usage = None # No usage info to trigger token estimation @@ -1801,6 +1848,7 @@ def test_prompt_cache_plan_records_unknown_capability_without_payload_directive( mock_chunk.choices = [MagicMock()] mock_chunk.choices[0].delta.content = "Response" mock_chunk.choices[0].delta.role = "assistant" + mock_chunk.choices[0].delta.reasoning = None mock_chunk.choices[0].delta.reasoning_content = None mock_chunk.usage = MagicMock() mock_chunk.usage.prompt_tokens = 10 @@ -1827,6 +1875,7 @@ def test_prompt_cache_usage_extracts_openai_cached_tokens(openai_model_instance) mock_chunk.choices = [MagicMock()] mock_chunk.choices[0].delta.content = "Response" mock_chunk.choices[0].delta.role = "assistant" + mock_chunk.choices[0].delta.reasoning = None mock_chunk.choices[0].delta.reasoning_content = None mock_chunk.usage = MagicMock() mock_chunk.usage.prompt_tokens = 100 @@ -1856,6 +1905,7 @@ def test_provider_adapter_preserves_context_manager_tool_order(openai_model_inst mock_chunk.choices = [MagicMock()] mock_chunk.choices[0].delta.content = "ok" mock_chunk.choices[0].delta.role = "assistant" + mock_chunk.choices[0].delta.reasoning = None mock_chunk.choices[0].delta.reasoning_content = None mock_chunk.choices[0].finish_reason = "stop" mock_chunk.usage = MagicMock(prompt_tokens=1, completion_tokens=1) @@ -1891,6 +1941,7 @@ def _make_content_chunk(content: str = "hi"): chunk.choices = [MagicMock()] chunk.choices[0].delta.content = content chunk.choices[0].delta.role = "assistant" + chunk.choices[0].delta.reasoning = None chunk.choices[0].delta.reasoning_content = None chunk.usage = MagicMock() chunk.usage.prompt_tokens = 1 @@ -2274,6 +2325,7 @@ def test_streaming_without_usage_falls_back_to_input_text(openai_model_instance) clean_delta = types.SimpleNamespace() clean_delta.content = "ok" clean_delta.role = "assistant" + clean_delta.reasoning = None clean_delta.reasoning_content = None clean_choice.delta = clean_delta clean_chunk.choices = [clean_choice]