Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 13 additions & 13 deletions backend/openedx_ai_extensions/processors/llm/llm_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand Down
23 changes: 23 additions & 0 deletions backend/tests/integration/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
12 changes: 8 additions & 4 deletions backend/tests/test_llm_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))],
Expand All @@ -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"

Expand Down
Loading