feat(plugins): pre_api_request/post_api_request with narrow payloads
- Rename per-LLM-call hooks from pre_llm_request/post_llm_request for clarity vs pre_llm_call - Emit summary kwargs only (counts, usage dict from normalize_usage); keep env_var_enabled for HERMES_DUMP_REQUESTS - Add is_truthy_value/env_var_enabled to utils; wire hermes_cli.plugins._env_enabled through it - Update Langfuse local setup doc; add scripts/langfuse_smoketest.py and optional ~/.hermes plugin tests Made-with: Cursor
This commit is contained in:
102
tests/test_langfuse_tracing_plugin_installed.py
Normal file
102
tests/test_langfuse_tracing_plugin_installed.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Smoke tests for the user-installed Langfuse plugin (when present).
|
||||
|
||||
The canonical plugin lives under ``~/.hermes/plugins/langfuse_tracing/``.
|
||||
These tests are skipped in CI unless that directory exists locally.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
PLUGIN_INIT = Path.home() / ".hermes" / "plugins" / "langfuse_tracing" / "__init__.py"
|
||||
|
||||
needs_user_plugin = pytest.mark.skipif(
|
||||
not PLUGIN_INIT.is_file(),
|
||||
reason="langfuse_tracing plugin not installed at ~/.hermes/plugins/langfuse_tracing/",
|
||||
)
|
||||
|
||||
|
||||
def _load_user_plugin():
|
||||
name = "langfuse_tracing_user_plugin"
|
||||
if name in sys.modules:
|
||||
return sys.modules[name]
|
||||
spec = importlib.util.spec_from_file_location(name, PLUGIN_INIT)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError("cannot load langfuse plugin")
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[name] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
@needs_user_plugin
|
||||
def test_langfuse_plugin_registers_api_request_hooks():
|
||||
mod = _load_user_plugin()
|
||||
ctx = MagicMock()
|
||||
ctx.manifest.name = "langfuse_tracing"
|
||||
mod.register(ctx)
|
||||
registered = [c[0][0] for c in ctx.register_hook.call_args_list]
|
||||
assert "pre_api_request" in registered
|
||||
assert "post_api_request" in registered
|
||||
assert "pre_llm_call" in registered
|
||||
|
||||
|
||||
@needs_user_plugin
|
||||
def test_pre_post_api_request_smoke_with_mock_langfuse():
|
||||
mod = _load_user_plugin()
|
||||
mod._TRACE_STATE.clear()
|
||||
|
||||
gen_obs = MagicMock()
|
||||
root_obs = MagicMock()
|
||||
root_obs.start_observation.return_value = gen_obs
|
||||
|
||||
client = MagicMock()
|
||||
client.create_trace_id.return_value = "trace-smoke-test"
|
||||
client.start_observation.return_value = root_obs
|
||||
|
||||
with patch.object(mod, "_get_langfuse", return_value=client):
|
||||
mod.on_pre_api_request(
|
||||
task_id="t1",
|
||||
session_id="s1",
|
||||
platform="cli",
|
||||
model="test/model",
|
||||
provider="openrouter",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
api_mode="chat_completions",
|
||||
api_call_count=1,
|
||||
message_count=3,
|
||||
tool_count=5,
|
||||
approx_input_tokens=100,
|
||||
request_char_count=400,
|
||||
max_tokens=4096,
|
||||
)
|
||||
mod.on_post_api_request(
|
||||
task_id="t1",
|
||||
session_id="s1",
|
||||
provider="openrouter",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
api_mode="chat_completions",
|
||||
model="test/model",
|
||||
api_call_count=1,
|
||||
api_duration=0.05,
|
||||
finish_reason="stop",
|
||||
usage={
|
||||
"input_tokens": 10,
|
||||
"output_tokens": 20,
|
||||
"total_tokens": 30,
|
||||
"reasoning_tokens": 0,
|
||||
"cache_read_tokens": 0,
|
||||
"cache_write_tokens": 0,
|
||||
},
|
||||
assistant_content_chars=42,
|
||||
assistant_tool_call_count=0,
|
||||
response_model="test/model",
|
||||
)
|
||||
|
||||
gen_obs.update.assert_called()
|
||||
gen_obs.end.assert_called()
|
||||
@@ -196,9 +196,9 @@ class TestPluginLoading:
|
||||
class TestPluginHooks:
|
||||
"""Tests for lifecycle hook registration and invocation."""
|
||||
|
||||
def test_valid_hooks_include_request_scoped_llm_hooks(self):
|
||||
assert "pre_llm_request" in VALID_HOOKS
|
||||
assert "post_llm_request" in VALID_HOOKS
|
||||
def test_valid_hooks_include_request_scoped_api_hooks(self):
|
||||
assert "pre_api_request" in VALID_HOOKS
|
||||
assert "post_api_request" in VALID_HOOKS
|
||||
|
||||
def test_register_and_invoke_hook(self, tmp_path, monkeypatch):
|
||||
"""Registered hooks are called on invoke_hook()."""
|
||||
@@ -270,7 +270,11 @@ class TestPluginHooks:
|
||||
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
||||
_make_plugin_dir(
|
||||
plugins_dir, "request_hook",
|
||||
register_body='ctx.register_hook("pre_llm_request", lambda **kw: {"seen": kw.get("api_call_count")})',
|
||||
register_body=(
|
||||
'ctx.register_hook("pre_api_request", '
|
||||
'lambda **kw: {"seen": kw.get("api_call_count"), '
|
||||
'"mc": kw.get("message_count"), "tc": kw.get("tool_count")})'
|
||||
),
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
||||
|
||||
@@ -278,15 +282,18 @@ class TestPluginHooks:
|
||||
mgr.discover_and_load()
|
||||
|
||||
results = mgr.invoke_hook(
|
||||
"pre_llm_request",
|
||||
"pre_api_request",
|
||||
session_id="s1",
|
||||
task_id="t1",
|
||||
model="test",
|
||||
api_call_count=2,
|
||||
messages=[],
|
||||
tools=[],
|
||||
message_count=5,
|
||||
tool_count=3,
|
||||
approx_input_tokens=100,
|
||||
request_char_count=400,
|
||||
max_tokens=8192,
|
||||
)
|
||||
assert results == [{"seen": 2}]
|
||||
assert results == [{"seen": 2, "mc": 5, "tc": 3}]
|
||||
|
||||
def test_invalid_hook_name_warns(self, tmp_path, monkeypatch, caplog):
|
||||
"""Registering an unknown hook name logs a warning."""
|
||||
|
||||
@@ -1454,7 +1454,7 @@ class TestRunConversation:
|
||||
assert mock_handle_function_call.call_args.kwargs["tool_call_id"] == "c1"
|
||||
assert mock_handle_function_call.call_args.kwargs["session_id"] == agent.session_id
|
||||
|
||||
def test_request_scoped_llm_hooks_fire_for_each_api_call(self, agent):
|
||||
def test_request_scoped_api_hooks_fire_for_each_api_call(self, agent):
|
||||
self._setup_agent(agent)
|
||||
tc = _mock_tool_call(name="web_search", arguments="{}", call_id="c1")
|
||||
resp1 = _mock_response(content="", finish_reason="tool_calls", tool_calls=[tc])
|
||||
@@ -1477,13 +1477,15 @@ class TestRunConversation:
|
||||
result = agent.run_conversation("search something")
|
||||
|
||||
assert result["final_response"] == "Done searching"
|
||||
pre_request_calls = [kw for name, kw in hook_calls if name == "pre_llm_request"]
|
||||
post_request_calls = [kw for name, kw in hook_calls if name == "post_llm_request"]
|
||||
pre_request_calls = [kw for name, kw in hook_calls if name == "pre_api_request"]
|
||||
post_request_calls = [kw for name, kw in hook_calls if name == "post_api_request"]
|
||||
assert len(pre_request_calls) == 2
|
||||
assert len(post_request_calls) == 2
|
||||
assert [call["api_call_count"] for call in pre_request_calls] == [1, 2]
|
||||
assert [call["api_call_count"] for call in post_request_calls] == [1, 2]
|
||||
assert all(call["session_id"] == agent.session_id for call in pre_request_calls)
|
||||
assert all("message_count" in c and "messages" not in c for c in pre_request_calls)
|
||||
assert all("usage" in c and "response" not in c for c in post_request_calls)
|
||||
|
||||
def test_interrupt_breaks_loop(self, agent):
|
||||
self._setup_agent(agent)
|
||||
|
||||
Reference in New Issue
Block a user