Skip to content
Open
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
14 changes: 6 additions & 8 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@ packages = [

[project.optional-dependencies]
langchain = ["langchain-core", "langchain", "langsmith (>=0.8.0)"]
openai = ["openai (>=2.8.0,<3.0.0)", "packaging (>=24.2,<25.0)", "openai-agents (>=0.4.0,<1.0.0)", "cryptography (>=50.0.0)", "mcp (>=1.27.2,<2)"]
crewai = ["crewai (>=0.152.0,<2.0.0); python_version < '3.14'", "litellm (>=1.83.14,<2.0.0); python_version < '3.14'", "uv (>=0.9.6); python_version < '3.14'", "aiohttp (>=3.14.1,<4); python_version < '3.14'", "cryptography (>=50.0.0)", "mcp (>=1.27.2,<2)", "pdfminer-six (>=20251107)"]
openai = ["openai (>=3.0.0,<4.0.0)", "packaging (>=24.2,<25.0)", "openai-agents (>=0.21.0,<1.0.0)", "cryptography (>=50.0.0)", "mcp (>=1.27.2,<2)"]
crewai = ["crewai (>=0.177.0,<2.0.0); python_version < '3.14'", "uv (>=0.9.6); python_version < '3.14'", "aiohttp (>=3.14.1,<4); python_version < '3.14'", "cryptography (>=50.0.0)", "mcp (>=1.27.2,<2)", "pdfminer-six (>=20251107)"]
middleware = ["starlette"]
all = ["langchain-core", "langchain", "langsmith (>=0.8.0)", "openai (>=2.8.0,<3.0.0)", "packaging (>=24.2,<25.0)", "openai-agents (>=0.4.0,<1.0.0)", "crewai (>=0.152.0,<2.0.0); python_version < '3.14'", "starlette", "litellm (>=1.83.14,<2.0.0); python_version < '3.14'", "uv (>=0.9.6); python_version < '3.14'", "aiohttp (>=3.14.1,<4); python_version < '3.14'", "cryptography (>=50.0.0)", "mcp (>=1.27.2,<2)", "pdfminer-six (>=20251107)"]
all = ["langchain-core", "langchain", "langsmith (>=0.8.0)", "openai (>=3.0.0,<4.0.0)", "packaging (>=24.2,<25.0)", "openai-agents (>=0.21.0,<1.0.0)", "crewai (>=0.177.0,<2.0.0); python_version < '3.14'", "starlette", "uv (>=0.9.6); python_version < '3.14'", "aiohttp (>=3.14.1,<4); python_version < '3.14'", "cryptography (>=50.0.0)", "mcp (>=1.27.2,<2)", "pdfminer-six (>=20251107)"]



Expand All @@ -45,14 +45,12 @@ python-dateutil = "^2.8.0"
langchain-core = { version = ">=1.3.3", optional = true }
langchain = { version = ">=1.3.9,<2.0.0", optional = true }
langsmith = { version = ">=0.8.0", optional = true }
openai = { version = ">=2.8.0,<3.0.0", optional = true }
openai-agents = { version = ">=0.4.0,<1.0.0", optional = true }
litellm = { version = ">=1.83.14,<2.0.0", optional = true, python = ">=3.11,<3.14" }
openai = { version = ">=3.0.0,<4.0.0", optional = true }
openai-agents = { version = ">=0.21.0,<1.0.0", optional = true }
galileo-core = "^4.4.0"
httpx = ">=0.27.0,<0.29.0"
starlette = { version = ">=1.3.1", optional = true }
backoff = "^2.2.1"
crewai = { version = ">=0.152.0,<2.0.0", optional = true, python = ">=3.11,<3.14" }
tqdm = { version = ">=4.0.0" }
typing-extensions = { version = ">=4.5.0" }
opentelemetry-sdk = "^1.38.0"
Expand Down Expand Up @@ -93,7 +91,7 @@ time-machine = "^2.17.0" # freezegun causes problems with pydantic model valida
pre-commit = "^4.0.1"
mypy = "^1.16.0"
invoke = "^2.2.0"
openai = ">=2.8.0,<3.0.0"
openai = ">=3.0.0,<4.0.0"
fastapi = "^0.135.0"
ruff = "^0.15.22"
openapi-python-client = "^0.29.0"
Expand Down
49 changes: 4 additions & 45 deletions src/splunk_ao/handlers/crewai/handler.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import hashlib
import json
import logging
from datetime import datetime
from typing import Any
from uuid import UUID

Expand All @@ -15,19 +14,16 @@
_logger = logging.getLogger(__name__)

# Lazy import state — crewai wraps sys.stdout/sys.stderr at import time, which
# causes pytest-xdist worker hangs. All crewai/litellm imports are deferred to
# causes pytest-xdist worker hangs. All crewai imports are deferred to
# _resolve_crewai_imports(), called once from CrewAIEventListener.__init__.
CREWAI_AVAILABLE = False
CREWAI_EVENTS_MODULE_AVAILABLE = False
LITE_LLM_AVAILABLE = False
litellm = None
_crewai_imports_resolved = False


def _resolve_crewai_imports() -> None:
"""Import crewai and litellm on first use, populating module-level globals."""
global CREWAI_AVAILABLE, CREWAI_EVENTS_MODULE_AVAILABLE
global LITE_LLM_AVAILABLE, litellm, _crewai_imports_resolved
"""Import crewai on first use, populating module-level globals."""
global CREWAI_AVAILABLE, CREWAI_EVENTS_MODULE_AVAILABLE, _crewai_imports_resolved

if _crewai_imports_resolved:
return
Expand All @@ -43,15 +39,6 @@ def _resolve_crewai_imports() -> None:
except ImportError:
_logger.warning("CrewAI not available, using stubs")

try:
import litellm as _litellm

litellm = _litellm
LITE_LLM_AVAILABLE = True
except ImportError:
_logger.warning("LiteLLM not available, using stubs")
litellm = None


class CrewAIEventListener:
"""
Expand Down Expand Up @@ -92,11 +79,6 @@ def __init__(
except ImportError:
_logger.warning("Could not import crewai event bus, skipping listener setup")

if LITE_LLM_AVAILABLE and litellm is not None:
if not litellm.success_callback:
litellm.success_callback = []
litellm.success_callback.append(self.lite_llm_usage_callback)

def setup_listeners(self, crewai_event_bus: Any) -> None:
"""Setup event listeners for CrewAI events."""
if not CREWAI_AVAILABLE:
Expand Down Expand Up @@ -345,12 +327,7 @@ def _generate_run_id(self, source: Any, event: Any) -> UUID:
task_id = getattr(getattr(event, "task", None), "id", "")
return self._hash_to_uuid(f"{event_agent.id}_{task_id}")

# 7. dict messages — lite_llm callback
if isinstance(event, dict) and "messages" in event:
messages = json.dumps(event["messages"])
return self._hash_to_uuid(messages)

# 8. Generic fallback
# 7. Generic fallback
return self._hash_to_uuid(
f"{getattr(event, 'crew_name', '')}_{getattr(event, 'agent', '')}_{getattr(event, 'task', '')}"
)
Expand Down Expand Up @@ -770,21 +747,3 @@ def _handle_memory_retrieval_failed(self, source: Any, event: Any) -> None:

self._handler.end_node(run_id=run_id, output=f"Memory retrieval failed: {metadata['error']}", metadata=metadata)

def lite_llm_usage_callback(
self,
kwargs: dict, # kwargs to completion
completion_response: Any, # response from completion
start_time: datetime,
end_time: datetime,
) -> None:
node_id = self._generate_run_id(kwargs, kwargs)

node = self._handler.get_node(node_id)
if not node:
_logger.debug(f"No node exists for run_id {node_id}")
return
usage = completion_response.model_extra["usage"]
node.span_params["usage"] = usage.model_dump() if hasattr(usage, "model_dump") else usage
node.span_params["num_input_tokens"] = getattr(usage, "prompt_tokens", 0)
node.span_params["num_output_tokens"] = getattr(usage, "completion_tokens", 0)
node.span_params["total_tokens"] = getattr(usage, "total_tokens", 0)
52 changes: 0 additions & 52 deletions tests/test_crewai_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,6 @@ def crewai_callback(mock_splunk_ao_logger):
with (
patch("splunk_ao.handlers.crewai.handler._crewai_imports_resolved", True),
patch("splunk_ao.handlers.crewai.handler.CREWAI_AVAILABLE", False),
patch("splunk_ao.handlers.crewai.handler.LITE_LLM_AVAILABLE", False),
):
from splunk_ao.handlers.crewai.handler import CrewAIEventListener

Expand All @@ -113,7 +112,6 @@ def test_initialization_with_crewai_available(mock_splunk_ao_logger) -> None:
with (
patch("splunk_ao.handlers.crewai.handler._crewai_imports_resolved", True),
patch("splunk_ao.handlers.crewai.handler.CREWAI_AVAILABLE", True),
patch("splunk_ao.handlers.crewai.handler.LITE_LLM_AVAILABLE", True),
):
from splunk_ao.handlers.crewai.handler import CrewAIEventListener

Expand All @@ -131,7 +129,6 @@ def test_initialization_with_crewai_unavailable(mock_splunk_ao_logger) -> None:
with (
patch("splunk_ao.handlers.crewai.handler._crewai_imports_resolved", True),
patch("splunk_ao.handlers.crewai.handler.CREWAI_AVAILABLE", False),
patch("splunk_ao.handlers.crewai.handler.LITE_LLM_AVAILABLE", False),
):
from splunk_ao.handlers.crewai.handler import CrewAIEventListener

Expand Down Expand Up @@ -693,55 +690,6 @@ def test_update_crew_input(crewai_callback: CrewAIEventListener) -> None:
assert "Research market trends" in mock_root_node.span_params["input"]


def test_lite_llm_usage_callback(crewai_callback: CrewAIEventListener) -> None:
"""Test LiteLLM usage callback."""
node_id = uuid.uuid4()

# Mock node
mock_node = Mock()
mock_node.span_params = {}

# Mock completion response with usage
mock_usage = Mock()
mock_usage.model_dump.return_value = {"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150}
mock_usage.prompt_tokens = 100
mock_usage.completion_tokens = 50
mock_usage.total_tokens = 150

mock_response = Mock()
mock_response.model_extra = {"usage": mock_usage}

kwargs = {"messages": [{"role": "user", "content": "test"}]}

with (
patch.object(crewai_callback._handler, "get_node", return_value=mock_node),
patch.object(crewai_callback, "_generate_run_id", return_value=node_id),
):
crewai_callback.lite_llm_usage_callback(
kwargs=kwargs, completion_response=mock_response, start_time=datetime.now(), end_time=datetime.now()
)

# Verify usage was recorded
assert mock_node.span_params["num_input_tokens"] == 100
assert mock_node.span_params["num_output_tokens"] == 50
assert mock_node.span_params["total_tokens"] == 150


def test_lite_llm_usage_callback_no_node(crewai_callback) -> None:
"""Test LiteLLM usage callback when node doesn't exist."""
kwargs = {"messages": [{"role": "user", "content": "test"}]}
mock_response = Mock()

with (
patch.object(crewai_callback._handler, "get_node", return_value=None),
patch.object(crewai_callback, "_generate_run_id", return_value=uuid.uuid4()),
):
# Should not raise an exception
crewai_callback.lite_llm_usage_callback(
kwargs=kwargs, completion_response=mock_response, start_time=datetime.now(), end_time=datetime.now()
)


# Memory event tests (for CrewAI >= 0.177.0)


Expand Down
30 changes: 19 additions & 11 deletions tests/test_openai_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,22 @@
import uuid
from unittest.mock import MagicMock, Mock, patch

import httpx
import httpx2
import pytest
import respx
import vcr
from agents import (
Agent,
CodeInterpreterTool,
GuardrailFunctionOutput,
InputGuardrail,
InputGuardrailTripwireTriggered,
OpenAIProvider,
RunConfig,
Runner,
set_trace_processors,
)
from agents.tracing import ResponseSpanData
from openai import AsyncOpenAI
from pydantic import BaseModel
from pytest import MonkeyPatch, mark

Expand Down Expand Up @@ -333,16 +335,22 @@ async def capture_payload(payload):

mock_response_data = _create_mock_response_with_tools(tool_calls)

with respx.mock(base_url="https://api.openai.com") as respx_mock:
respx_mock.post("/v1/responses").mock(return_value=httpx.Response(200, json=mock_response_data))
agent = Agent(
name="Assistant",
instructions="You are a helpful assistant.",
tools=[CodeInterpreterTool(tool_config={"type": "code_interpreter", "container": {"type": "auto"}})],
)
def mock_handler(request: httpx2.Request) -> httpx2.Response:
return httpx2.Response(200, json=mock_response_data)

result = await Runner.run(agent, "Test multiple tools.")
assert result
mock_openai = AsyncOpenAI(
api_key="sk-test",
http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(mock_handler)),
)
provider = OpenAIProvider(openai_client=mock_openai)
agent = Agent(
name="Assistant",
instructions="You are a helpful assistant.",
tools=[CodeInterpreterTool(tool_config={"type": "code_interpreter", "container": {"type": "auto"}})],
)

result = await Runner.run(agent, "Test multiple tools.", run_config=RunConfig(model_provider=provider))
assert result

traces = splunk_ao_logger.traces
assert len(traces) == 1
Expand Down
Loading