From 24f194dfd41ed25c027f9bbe3a6e9780f67bc0f3 Mon Sep 17 00:00:00 2001 From: henrrypg Date: Wed, 17 Jun 2026 17:23:43 -0500 Subject: [PATCH 1/3] fix: integration tests --- .../processors/llm/llm_processor.py | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/backend/openedx_ai_extensions/processors/llm/llm_processor.py b/backend/openedx_ai_extensions/processors/llm/llm_processor.py index c90b0b51..628a901e 100644 --- a/backend/openedx_ai_extensions/processors/llm/llm_processor.py +++ b/backend/openedx_ai_extensions/processors/llm/llm_processor.py @@ -263,19 +263,19 @@ def _completion_with_tools(self, tool_calls, params): # Ensure tool exists if function_name not in AVAILABLE_TOOLS: logger.error(f"Tool '{function_name}' requested by LLM but not available locally.") - continue - - function_to_call = AVAILABLE_TOOLS[function_name] - - try: - function_args = json.loads(tool_call.function.arguments) - function_response = function_to_call(**function_args) - except json.JSONDecodeError: - function_response = "Error: Invalid JSON arguments provided." - logger.error(f"Failed to parse JSON arguments for {function_name}") - except Exception as e: # pylint: disable=broad-exception-caught - function_response = f"Error executing tool: {str(e)}" - logger.error(f"Error executing tool {function_name}: {e}") + function_response = f"Error: Tool '{function_name}' not found." + else: + function_to_call = AVAILABLE_TOOLS[function_name] + + try: + function_args = json.loads(tool_call.function.arguments) + function_response = function_to_call(**function_args) + except json.JSONDecodeError: + function_response = "Error: Invalid JSON arguments provided." + logger.error(f"Failed to parse JSON arguments for {function_name}") + except Exception as e: # pylint: disable=broad-exception-caught + function_response = f"Error executing tool: {str(e)}" + logger.error(f"Error executing tool {function_name}: {e}") params["messages"].append( { From 04def07b74484554bacc4a0dd9b83846fb59926f Mon Sep 17 00:00:00 2001 From: henrrypg Date: Wed, 17 Jun 2026 17:32:15 -0500 Subject: [PATCH 2/3] fix: integration tests --- backend/tests/test_llm_processor.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/backend/tests/test_llm_processor.py b/backend/tests/test_llm_processor.py index 30e58ef9..d55a5e0a 100644 --- a/backend/tests/test_llm_processor.py +++ b/backend/tests/test_llm_processor.py @@ -1247,8 +1247,9 @@ def test_completion_with_tools_unknown_tool_is_skipped( mock_completion, llm_processor # pylint: disable=redefined-outer-name ): """ - When a tool call references a function not in AVAILABLE_TOOLS the call is - silently skipped (logged) and completion proceeds without a tool message. + When a tool call references a function not in AVAILABLE_TOOLS, an error + tool message is appended (logged) so every tool_call_id still gets a + response, and completion proceeds normally. """ mock_completion.return_value = Mock( choices=[Mock(message=Mock(content="done", tool_calls=None))], @@ -1266,8 +1267,11 @@ def test_completion_with_tools_unknown_tool_is_skipped( # pylint: disable=protected-access response = llm_processor._completion_with_tools([unknown_call], params) - # No tool message should have been appended - assert all(m.get("role") != "tool" for m in params["messages"]) + # An error tool message should have been appended for the unknown tool's call_id + tool_messages = [m for m in params["messages"] if m.get("role") == "tool"] + assert len(tool_messages) == 1 + assert tool_messages[0]["tool_call_id"] == "call_x" + assert "not found" in tool_messages[0]["content"].lower() mock_completion.assert_called_once() assert response.choices[0].message.content == "done" From d1af6b28c46c583904e63aae4b8df89d8f9dbc60 Mon Sep 17 00:00:00 2001 From: henrrypg Date: Thu, 18 Jun 2026 12:43:03 -0500 Subject: [PATCH 3/3] chore: remove log litellm exceptions --- backend/tests/integration/conftest.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/backend/tests/integration/conftest.py b/backend/tests/integration/conftest.py index ecd4353f..7c75e8a4 100644 --- a/backend/tests/integration/conftest.py +++ b/backend/tests/integration/conftest.py @@ -4,11 +4,14 @@ Fixtures here are auto-loaded by pytest for all tests under tests/integration/. """ +import asyncio import json +import logging import os import sys from unittest.mock import MagicMock +import litellm import pytest from django.contrib.auth import get_user_model from opaque_keys.edx.keys import CourseKey @@ -151,3 +154,23 @@ def live_api_client(live_user): # pylint: disable=redefined-outer-name,unused-a client = APIClient() client.login(username=LIVE_USER_USERNAME, password=LIVE_USER_PASSWORD) return client + + +@pytest.fixture(scope="session", autouse=True) +def _close_litellm_async_clients(): + """ + Close litellm's pooled httpx async clients while an event loop is alive, + and silence logging's own error reporting for what's left. + + litellm registers an atexit hook (async_client_cleanup.py) that + unconditionally calls asyncio.get_event_loop() at interpreter shutdown, + after pytest's event loop and captured stdio are already gone. That hook + fires after every fixture/teardown has already run, so it can't be + avoided from here — only its noise can. logging.raiseExceptions = False + is Python's documented way to suppress "Logging error" cascades that + happen when a handler tries to write to an already-closed stream during + shutdown. + """ + yield + asyncio.run(litellm.close_litellm_async_clients()) + logging.raiseExceptions = False