Files
aiturk-hermes-ide/tests/plugins/test_langfuse_plugin.py

2390 lines
93 KiB
Python

"""Tests for the bundled observability/langfuse plugin."""
from __future__ import annotations
import importlib
import logging
import sys
from decimal import Decimal
from pathlib import Path
from types import SimpleNamespace
import pytest
import yaml
REPO_ROOT = Path(__file__).resolve().parents[2]
PLUGIN_DIR = REPO_ROOT / "plugins" / "observability" / "langfuse"
# ---------------------------------------------------------------------------
# Manifest + layout
# ---------------------------------------------------------------------------
class TestManifest:
def test_manifest_fields(self):
data = yaml.safe_load((PLUGIN_DIR / "plugin.yaml").read_text())
assert data["name"] == "langfuse"
assert data["version"]
# All eleven hooks the plugin implements.
assert set(data["hooks"]) == {
"pre_api_request", "post_api_request", "api_request_error",
"pre_llm_call", "post_llm_call",
"pre_tool_call", "post_tool_call",
"on_session_finalize", "on_session_end",
"subagent_start", "subagent_stop",
}
# Required env vars are the user-facing HERMES_ prefixed keys.
assert "HERMES_LANGFUSE_PUBLIC_KEY" in data["requires_env"]
assert "HERMES_LANGFUSE_SECRET_KEY" in data["requires_env"]
# ---------------------------------------------------------------------------
# Plugin discovery: langfuse is opt-in (not loaded unless explicitly enabled).
# This guards against someone accidentally re-introducing a per-hook
# load_config() gate or making the plugin auto-load.
# ---------------------------------------------------------------------------
class TestDiscovery:
def test_plugin_is_discovered_as_standalone_opt_in(self, tmp_path, monkeypatch):
"""Scanner should find the plugin but NOT load it by default."""
from hermes_cli import plugins as plugins_mod
# Isolated HERMES_HOME so we don't read the developer's config.yaml.
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
manager = plugins_mod.PluginManager()
manager.discover_and_load()
# observability/langfuse appears in the plugin registry …
loaded = manager._plugins.get("observability/langfuse")
assert loaded is not None, "plugin not discovered"
# … but is not loaded (opt-in default → no config.yaml means nothing enabled)
assert loaded.enabled is False
assert "not enabled" in (loaded.error or "").lower()
# ---------------------------------------------------------------------------
# Runtime gate: _get_langfuse() returns None and caches _INIT_FAILED when
# credentials are missing. Guards against regressing toward the rejected
# per-hook load_config() design.
# ---------------------------------------------------------------------------
class TestRuntimeGate:
def _fresh_plugin(self):
"""Import the plugin module fresh (clears any cached client)."""
mod_name = "plugins.observability.langfuse"
sys.modules.pop(mod_name, None)
return importlib.import_module(mod_name)
def test_get_langfuse_returns_none_without_credentials(self, monkeypatch):
for k in (
"HERMES_LANGFUSE_PUBLIC_KEY", "HERMES_LANGFUSE_SECRET_KEY",
"LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY",
):
monkeypatch.delenv(k, raising=False)
langfuse_plugin = self._fresh_plugin()
assert langfuse_plugin._get_langfuse() is None
def test_missing_sdk_logs_one_warning(self, monkeypatch, caplog):
langfuse_plugin = self._fresh_plugin()
monkeypatch.setattr(langfuse_plugin, "Langfuse", None)
langfuse_plugin._LANGFUSE_CLIENT = None
with caplog.at_level(logging.WARNING, logger=langfuse_plugin.__name__):
assert langfuse_plugin._get_langfuse() is None
assert langfuse_plugin._get_langfuse() is None
messages = [record.getMessage() for record in caplog.records]
assert len(messages) == 1
assert "SDK is unavailable" in messages[0]
assert "tracing is disabled" in messages[0]
def test_get_langfuse_caches_failure_no_config_load(self, monkeypatch):
"""A miss must be cached — no per-hook config.yaml reads, no env re-reads."""
for k in (
"HERMES_LANGFUSE_PUBLIC_KEY", "HERMES_LANGFUSE_SECRET_KEY",
"LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY",
):
monkeypatch.delenv(k, raising=False)
langfuse_plugin = self._fresh_plugin()
# Prime the cache with one call.
assert langfuse_plugin._get_langfuse() is None
# Now block os.environ.get — a correctly-cached plugin must not
# touch env again.
import os
called = {"n": 0}
real_get = os.environ.get
def tracking_get(key, default=None):
if key.startswith(("HERMES_LANGFUSE_", "LANGFUSE_")):
called["n"] += 1
return real_get(key, default)
monkeypatch.setattr(os.environ, "get", tracking_get)
for _ in range(20):
assert langfuse_plugin._get_langfuse() is None
assert called["n"] == 0, (
f"_get_langfuse() re-read env {called['n']} times after cache miss — "
"it should short-circuit via _INIT_FAILED"
)
# ---------------------------------------------------------------------------
# Hooks are inert when the client is unavailable.
# ---------------------------------------------------------------------------
class TestHooksInert:
def test_hooks_noop_without_client(self, monkeypatch):
"""All 6 hooks must return without raising when _get_langfuse() is None."""
for k in (
"HERMES_LANGFUSE_PUBLIC_KEY", "HERMES_LANGFUSE_SECRET_KEY",
"LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY",
):
monkeypatch.delenv(k, raising=False)
sys.modules.pop("plugins.observability.langfuse", None)
import importlib
mod = importlib.import_module("plugins.observability.langfuse")
# Each hook should just return; no exceptions.
mod.on_pre_llm_call(task_id="t", session_id="s", messages=[{"role": "user", "content": "hi"}])
mod.on_pre_llm_request(task_id="t", session_id="s", api_call_count=1, request_messages=[])
mod.on_post_llm_call(task_id="t", session_id="s", api_call_count=1)
mod.on_pre_tool_call(tool_name="read_file", args={}, task_id="t", session_id="s")
mod.on_post_tool_call(tool_name="read_file", args={}, result="ok", task_id="t", session_id="s")
class TestPayloadSanitization:
def test_safe_value_redacts_base64_data_uri_instead_of_truncating(self):
sys.modules.pop("plugins.observability.langfuse", None)
import importlib
mod = importlib.import_module("plugins.observability.langfuse")
payload = "data:image/png;base64," + ("a" * 20000)
result = mod._safe_value(payload)
assert result == {
"type": "data_uri",
"media_type": "image/png",
"omitted": True,
"length": len(payload),
}
def test_serialize_messages_redacts_data_uri_parts(self):
sys.modules.pop("plugins.observability.langfuse", None)
import importlib
mod = importlib.import_module("plugins.observability.langfuse")
payload = "data:image/jpeg;base64," + ("b" * 20000)
serialized = mod._serialize_messages([
{"role": "user", "content": [{"type": "image_url", "image_url": {"url": payload}}]}
])
assert serialized[0]["content"][0]["image_url"]["url"] == {
"type": "data_uri",
"media_type": "image/jpeg",
"omitted": True,
"length": len(payload),
}
class TestTraceScopeKey:
def _fresh_plugin(self):
mod_name = "plugins.observability.langfuse"
sys.modules.pop(mod_name, None)
return importlib.import_module(mod_name)
def test_trace_key_scopes_by_turn_id_when_available(self):
plugin = self._fresh_plugin()
key_a = plugin._trace_key("task-1", "session-1", turn_id="turn-a")
key_b = plugin._trace_key("task-1", "session-1", turn_id="turn-b")
assert key_a != key_b
assert "turn:turn-a" in key_a
assert "turn:turn-b" in key_b
# ---------------------------------------------------------------------------
# End-to-end collision regression: two turns of ONE gateway session must not
# share trace state. The helper-level tests above prove _trace_key returns
# distinct keys; this drives the real pre/post hooks to prove the keys are
# actually threaded through so the second turn gets its own root trace.
#
# Gateway reality this reproduces:
# * task_id == session_id for every turn (gateway/run.py)
# * turn_id is unique per turn (turn_context.py)
# * api_call_count resets to 1 each turn (conversation_loop.py)
#
# Before the turn/request scoping, _trace_key collapsed to the constant
# session_id. That worked only because _finish_trace pops the key on a clean
# turn end. When turn 1 does NOT finalize (interrupted, tool-only final step,
# or empty final content), its state lingered under session_id and turn 2
# silently merged into turn 1's trace instead of opening its own.
# ---------------------------------------------------------------------------
class TestTurnTraceIsolation:
def _fresh_plugin(self):
sys.modules.pop("plugins.observability.langfuse", None)
return importlib.import_module("plugins.observability.langfuse")
@staticmethod
def _fake_client(started):
"""A minimal Langfuse stand-in that records each root trace opened.
``_start_root_trace`` calls ``create_trace_id`` then opens a root via
``start_as_current_observation(...)`` (a context manager whose
``__enter__`` returns the root span). We record one entry per root
actually opened so the test can count distinct traces.
"""
class _Span:
def update(self, **kw):
pass
def end(self, **kw):
pass
def set_trace_io(self, **kw):
pass
def start_observation(self, **kw):
return _Span()
class _RootCM:
def __enter__(self):
return _Span()
def __exit__(self, *exc):
return False
class _Client:
def create_trace_id(self, seed=None):
return f"trace::{seed}"
def start_as_current_observation(self, **kw):
started.append(kw.get("trace_context", {}).get("trace_id"))
return _RootCM()
def flush(self):
pass
return _Client()
def _run_turn(self, mod, *, session, turn_n, finalize):
"""Drive one turn through the request-scoped hooks the gateway fires."""
task_id = session # gateway sets task_id == session_id
turn_id = f"{session}:{task_id}:turn{turn_n}"
api_call_count = 1 # resets every turn
api_request_id = f"{turn_id}:api:{api_call_count}"
mod.on_pre_llm_request(
task_id=task_id,
session_id=session,
model="m",
provider="p",
api_mode="chat",
api_call_count=api_call_count,
request_messages=[{"role": "user", "content": "hi"}],
turn_id=turn_id,
api_request_id=api_request_id,
)
# finalize=False => leave a tool call on the final response so
# _finish_trace is skipped and the turn's state lingers.
mod.on_post_llm_call(
task_id=task_id,
session_id=session,
model="m",
provider="p",
api_mode="chat",
api_call_count=api_call_count,
assistant_content_chars=5 if finalize else 0,
assistant_tool_call_count=0 if finalize else 1,
usage={"input_tokens": 10, "output_tokens": 5},
turn_id=turn_id,
api_request_id=api_request_id,
)
def test_unfinalized_turn_does_not_capture_next_turn(self, monkeypatch):
"""A turn that never finalizes must not absorb the following turn."""
mod = self._fresh_plugin()
started: list = []
monkeypatch.setattr(mod, "_get_langfuse", lambda: self._fake_client(started))
monkeypatch.setattr(mod, "_end_observation", lambda *a, **k: None)
mod._TRACE_STATE.clear()
# Turn 1 ends without finalizing (its final step still has a tool call).
self._run_turn(mod, session="sess-iso", turn_n=1, finalize=False)
# Turn 2 is a normal, fully finalizing turn in the SAME session.
self._run_turn(mod, session="sess-iso", turn_n=2, finalize=True)
# Each turn opened its OWN root trace. On the pre-fix code the second
# turn reused turn 1's lingering state and only one trace was opened.
assert len(started) == 2
# Turn 2 finalized and was popped by _finish_trace; only turn 1's
# (non-finalizing) state lingers. Assert the surviving key is turn 1's
# and that turn 2 never merged into it — `all(...)` over an empty set
# would pass vacuously, so pin the exact surviving key instead.
keys = list(mod._TRACE_STATE.keys())
assert len(keys) == 1
assert "turn1" in keys[0]
assert "turn2" not in keys[0]
def test_pre_and_post_hooks_share_one_key_within_a_turn(self, monkeypatch):
"""turn_id is preferred over api_request_id so the turn-scoped
post_llm_call (which carries no api_request_id) still resolves to the
same key as the request-scoped pre/post_api_request hooks. If the
ordering were reversed, finalization would silently break."""
mod = self._fresh_plugin()
turn_id = "S:T:turnX"
api_request_id = f"{turn_id}:api:1"
k_pre_api = mod._trace_key("T", "S", turn_id=turn_id, api_request_id=api_request_id)
k_post_api = mod._trace_key("T", "S", turn_id=turn_id, api_request_id=api_request_id)
k_post_turn = mod._trace_key("T", "S", turn_id=turn_id, api_request_id="")
assert k_pre_api == k_post_api == k_post_turn
def test_non_finalizing_turns_do_not_grow_state_unboundedly(self, monkeypatch):
"""Per-turn keys mean a turn that never finalizes leaves a lingering
entry. Without a cap that grows once per non-finalizing turn forever;
the LRU eviction must bound _TRACE_STATE at _MAX_TRACE_STATE.
"""
mod = self._fresh_plugin()
started: list = []
monkeypatch.setattr(mod, "_get_langfuse", lambda: self._fake_client(started))
monkeypatch.setattr(mod, "_end_observation", lambda *a, **k: None)
monkeypatch.setattr(mod, "_MAX_TRACE_STATE", 8)
mod._TRACE_STATE.clear()
# Far more non-finalizing turns than the cap.
for n in range(50):
self._run_turn(mod, session="sess-leak", turn_n=n, finalize=False)
assert len(mod._TRACE_STATE) <= 8
# The survivors are the most-recently-updated turns (LRU eviction).
surviving = sorted(int(k.rsplit("turn", 1)[1]) for k in mod._TRACE_STATE)
assert surviving == list(range(42, 50))
def test_finish_trace_exits_root_context_manager(self, monkeypatch):
"""_finish_trace must call root_ctx.__exit__(), not just root_span.end().
Regression for the "Exception ignored in: <generator>" traceback
on CLI exit. The plugin enters the root observation's context
manager (start_as_current_observation(...).__enter__()) but must
also exit it; otherwise the generator is left suspended and is
only unwound when the GC collects it during interpreter teardown.
By then opentelemetry.trace.Span has been set to None, and the
generator's close() -> use_span.__exit__ -> isinstance(span, Span)
raises TypeError: isinstance() arg 2 must be a type. Exiting the
context manager here unwinds the generator while modules are intact.
"""
mod = self._fresh_plugin()
started: list = []
monkeypatch.setattr(mod, "_end_observation", lambda *a, **k: None)
mod._TRACE_STATE.clear()
exited: list = []
class _S:
def update(self, **kw): pass
def end(self, **kw): pass
def set_trace_io(self, **kw): pass
def start_observation(self, **kw): return _S()
class _TrackingRootCM:
def __enter__(self):
return _S()
def __exit__(self, *exc):
exited.append(exc)
return False
class _TrackingClient:
def create_trace_id(self, seed=None):
return f"trace::{seed}"
def start_as_current_observation(self, **kw):
started.append(kw.get("trace_context", {}).get("trace_id"))
return _TrackingRootCM()
def flush(self):
pass
monkeypatch.setattr(mod, "_get_langfuse", lambda: _TrackingClient())
self._run_turn(mod, session="sess-exit", turn_n=1, finalize=True)
assert exited, (
"_finish_trace did not call root_ctx.__exit__; the generator is "
"left suspended and will raise TypeError on GC at interpreter "
"teardown when opentelemetry.trace.Span is None"
)
assert len(exited) == 1
assert exited[0] == (None, None, None)
# ---------------------------------------------------------------------------
# Placeholder-credential guard (#23823).
#
# Regression coverage for the silent-failure bug: when an operator leaves
# HERMES_LANGFUSE_PUBLIC_KEY / SECRET_KEY at a template value like
# "placeholder", "test-key", or "your-langfuse-key", the SDK accepts the
# credentials at construction time (it does no server-side validation
# eagerly) but drops every trace at flush time, with no signal in the
# Hermes logs. The fix in `_get_langfuse()` validates the documented
# `pk-lf-` / `sk-lf-` prefix Langfuse always issues, surfaces a one-shot
# warning naming the offending env var(s), and short-circuits via the
# same `_INIT_FAILED` path used for missing credentials so subsequent
# hook invocations don't re-log.
# ---------------------------------------------------------------------------
class _FakeLangfuse:
"""Stand-in for the real :class:`langfuse.Langfuse` so tests don't
need the optional ``langfuse`` SDK installed. The plugin's runtime
gate refuses to proceed past ``if Langfuse is None`` when the SDK
is missing, which would short-circuit before the placeholder check
can fire. Patching ``plugin.Langfuse`` with this class lets the
placeholder validator exercise its full code path."""
instances: list["_FakeLangfuse"] = []
def __init__(self, **kwargs):
self.kwargs = kwargs
_FakeLangfuse.instances.append(self)
class TestPlaceholderKeyDetection:
LOGGER_NAME = "plugins.observability.langfuse"
def _fresh_plugin(self, monkeypatch=None):
mod_name = "plugins.observability.langfuse"
sys.modules.pop(mod_name, None)
mod = importlib.import_module(mod_name)
if monkeypatch is not None:
# Pretend the SDK is installed so `_get_langfuse()` actually
# reaches the placeholder check. Real SDK calls are never
# made because the placeholder/missing-credentials paths
# return before constructing a client.
_FakeLangfuse.instances.clear()
monkeypatch.setattr(mod, "Langfuse", _FakeLangfuse, raising=False)
return mod
@staticmethod
def _clear_env(monkeypatch):
for k in (
"HERMES_LANGFUSE_PUBLIC_KEY", "HERMES_LANGFUSE_SECRET_KEY",
"LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY",
):
monkeypatch.delenv(k, raising=False)
# -- helper unit tests (no SDK stub needed: these don't go through
# _get_langfuse, they exercise the pure-Python helpers directly) ------
def test_validate_langfuse_key_accepts_documented_prefix(self, monkeypatch):
self._clear_env(monkeypatch)
plugin = self._fresh_plugin()
assert plugin._validate_langfuse_key(
"HERMES_LANGFUSE_PUBLIC_KEY", "pk-lf-real-public-xyz"
) is None
assert plugin._validate_langfuse_key(
"HERMES_LANGFUSE_SECRET_KEY", "sk-lf-real-secret-xyz"
) is None
# -- end-to-end _get_langfuse() behaviour --------------------------------
# These tests pass `monkeypatch` to _fresh_plugin() so the helper can
# stub out `Langfuse` (the optional SDK). Without that, every call
# short-circuits at `if Langfuse is None` before reaching the
# placeholder validator — masking the very behaviour we're testing.
def test_placeholder_public_key_warns_and_skips(self, monkeypatch, caplog):
self._clear_env(monkeypatch)
monkeypatch.setenv("HERMES_LANGFUSE_PUBLIC_KEY", "placeholder")
monkeypatch.setenv("HERMES_LANGFUSE_SECRET_KEY", "sk-lf-real-secret-xyz")
plugin = self._fresh_plugin(monkeypatch)
with caplog.at_level(logging.WARNING, logger=self.LOGGER_NAME):
assert plugin._get_langfuse() is None
text = caplog.text
assert "HERMES_LANGFUSE_PUBLIC_KEY" in text
assert "'placeholder'" in text
assert "pk-lf-" in text
# The valid secret value must NOT appear (the var NAME does, in
# the "or unset ..." hint, but the value preview shouldn't).
assert "'sk-lf-" not in text
# Never constructed the SDK client — short-circuited before that.
assert _FakeLangfuse.instances == []
def test_placeholder_secret_key_warns_and_skips(self, monkeypatch, caplog):
self._clear_env(monkeypatch)
monkeypatch.setenv("HERMES_LANGFUSE_PUBLIC_KEY", "pk-lf-real-public-xyz")
monkeypatch.setenv("HERMES_LANGFUSE_SECRET_KEY", "test-key")
plugin = self._fresh_plugin(monkeypatch)
with caplog.at_level(logging.WARNING, logger=self.LOGGER_NAME):
assert plugin._get_langfuse() is None
text = caplog.text
assert "HERMES_LANGFUSE_SECRET_KEY" in text
assert "'test-key'" in text
assert "sk-lf-" in text
# The valid public value must NOT appear.
assert "'pk-lf-" not in text
assert _FakeLangfuse.instances == []
def test_both_placeholders_one_warning_with_both_keys(self, monkeypatch, caplog):
self._clear_env(monkeypatch)
monkeypatch.setenv("HERMES_LANGFUSE_PUBLIC_KEY", "placeholder")
monkeypatch.setenv("HERMES_LANGFUSE_SECRET_KEY", "placeholder")
plugin = self._fresh_plugin(monkeypatch)
with caplog.at_level(logging.WARNING, logger=self.LOGGER_NAME):
assert plugin._get_langfuse() is None
warnings = [r for r in caplog.records if r.levelname == "WARNING"
and r.name == self.LOGGER_NAME]
assert len(warnings) == 1, (
f"Expected a single combined warning; got {len(warnings)}:\n"
+ "\n".join(r.getMessage() for r in warnings)
)
text = warnings[0].getMessage()
assert "HERMES_LANGFUSE_PUBLIC_KEY" in text
assert "HERMES_LANGFUSE_SECRET_KEY" in text
def test_repeated_calls_do_not_re_warn(self, monkeypatch, caplog):
"""The cached ``_INIT_FAILED`` sentinel must short-circuit
subsequent calls so each hook invocation isn't a fresh log
line — otherwise a busy gateway will spam the operator's
terminal."""
self._clear_env(monkeypatch)
monkeypatch.setenv("HERMES_LANGFUSE_PUBLIC_KEY", "placeholder")
monkeypatch.setenv("HERMES_LANGFUSE_SECRET_KEY", "placeholder")
plugin = self._fresh_plugin(monkeypatch)
with caplog.at_level(logging.WARNING, logger=self.LOGGER_NAME):
for _ in range(15):
assert plugin._get_langfuse() is None
warnings = [r for r in caplog.records if r.levelname == "WARNING"
and r.name == self.LOGGER_NAME]
assert len(warnings) == 1, (
f"Warning fired {len(warnings)} times across 15 calls; "
"expected 1 (cached via _INIT_FAILED)"
)
class TestRequestMessageCoercion:
def test_prefers_request_messages_then_messages_then_history_then_user_message(self):
sys.modules.pop("plugins.observability.langfuse", None)
mod = importlib.import_module("plugins.observability.langfuse")
assert mod._coerce_request_messages(
request_messages=[{"role": "system", "content": "s"}],
messages=[{"role": "user", "content": "m"}],
conversation_history=[{"role": "user", "content": "h"}],
user_message="u",
) == [{"role": "system", "content": "s"}]
assert mod._coerce_request_messages(
messages=[{"role": "user", "content": "m"}],
conversation_history=[{"role": "user", "content": "h"}],
user_message="u",
) == [{"role": "user", "content": "m"}]
assert mod._coerce_request_messages(
conversation_history=[{"role": "user", "content": "h"}],
user_message="u",
) == [{"role": "user", "content": "h"}]
assert mod._coerce_request_messages(user_message="u") == [{"role": "user", "content": "u"}]
def test_messages_for_langfuse_includes_anthropic_system_param(self):
sys.modules.pop("plugins.observability.langfuse", None)
mod = importlib.import_module("plugins.observability.langfuse")
out = mod._messages_for_langfuse_input(
request_messages=[{"role": "user", "content": "hi"}],
system_prompt="You are Hermes.",
)
assert out[0]["role"] == "system"
assert out[0]["content"] == "You are Hermes."
assert out[1]["role"] == "user"
def test_messages_for_langfuse_skips_duplicate_system(self):
sys.modules.pop("plugins.observability.langfuse", None)
mod = importlib.import_module("plugins.observability.langfuse")
out = mod._messages_for_langfuse_input(
request_messages=[
{"role": "system", "content": "already here"},
{"role": "user", "content": "hi"},
],
system_prompt="ignored when messages include system",
)
assert out[0]["role"] == "system"
assert out[0]["content"] == "already here"
assert out[1]["role"] == "user"
class TestAssistantMessageSerialization:
def test_serialize_assistant_message_prefers_reasoning(self):
sys.modules.pop("plugins.observability.langfuse", None)
mod = importlib.import_module("plugins.observability.langfuse")
message = SimpleNamespace(
content="answer",
reasoning="primary reasoning",
reasoning_content="fallback reasoning",
reasoning_details=[{"type": "summary", "text": "structured reasoning"}],
)
assert mod._serialize_assistant_message(message)["reasoning"] == "primary reasoning"
def test_serialize_assistant_message_uses_reasoning_content_when_reasoning_absent(self):
sys.modules.pop("plugins.observability.langfuse", None)
mod = importlib.import_module("plugins.observability.langfuse")
message = SimpleNamespace(
content="answer",
reasoning=None,
reasoning_content="provider scratchpad",
reasoning_details=[{"type": "summary", "text": "structured reasoning"}],
)
assert mod._serialize_assistant_message(message)["reasoning"] == "provider scratchpad"
def test_serialize_assistant_message_uses_structured_reasoning_details(self):
sys.modules.pop("plugins.observability.langfuse", None)
mod = importlib.import_module("plugins.observability.langfuse")
reasoning_details = [
{"type": "summary", "text": "checked tools"},
{"type": "encrypted_content", "encrypted_content": b"opaque"},
]
message = SimpleNamespace(
content="answer",
reasoning=None,
reasoning_content=None,
reasoning_details=reasoning_details,
)
assert mod._serialize_assistant_message(message)["reasoning"] == [
{"type": "summary", "text": "checked tools"},
{"type": "encrypted_content", "encrypted_content": {"type": "bytes", "len": 6}},
]
def test_serialize_assistant_message_without_reasoning_fields_sets_none(self):
sys.modules.pop("plugins.observability.langfuse", None)
mod = importlib.import_module("plugins.observability.langfuse")
message = SimpleNamespace(content="answer")
assert mod._serialize_assistant_message(message)["reasoning"] is None
class TestToolCallOutputBackfill:
def test_post_tool_call_backfills_matching_turn_tool_call_output(self, monkeypatch):
sys.modules.pop("plugins.observability.langfuse", None)
mod = importlib.import_module("plugins.observability.langfuse")
observation = object()
state = mod.TraceState(trace_id="trace-1", root_ctx=None, root_span=None)
state.tools["call-1"] = observation
state.turn_tool_calls.append({
"id": "call-1",
"type": "function",
"name": "web_extract",
"arguments": '{"urls": ["https://example.com"]}',
"function": {
"name": "web_extract",
"arguments": '{"urls": ["https://example.com"]}',
},
})
task_key = mod._trace_key("task-1", "session-1")
monkeypatch.setitem(mod._TRACE_STATE, task_key, state)
ended = {}
def fake_end_observation(obs, *, output=None, metadata=None, usage_details=None, cost_details=None):
ended["observation"] = obs
ended["output"] = output
ended["metadata"] = metadata
monkeypatch.setattr(mod, "_end_observation", fake_end_observation)
mod.on_post_tool_call(
tool_name="web_extract",
args={"urls": ["https://example.com"]},
result='{"results": [{"url": "https://example.com", "content": "Example Domain"}]}',
task_id="task-1",
session_id="session-1",
tool_call_id="call-1",
)
assert ended["observation"] is observation
assert state.turn_tool_calls[0]["output"] == ended["output"]
assert state.turn_tool_calls[0]["function"]["output"] == ended["output"]
assert state.turn_tool_calls[0]["output"] == {
"results": [{"url": "https://example.com", "content": "Example Domain"}]
}
def test_serialize_messages_keeps_tool_name_and_call_id(self):
sys.modules.pop("plugins.observability.langfuse", None)
mod = importlib.import_module("plugins.observability.langfuse")
messages = [{
"role": "tool",
"name": "web_extract",
"tool_call_id": "call-1",
"content": '{"ok": true}',
}]
assert mod._serialize_messages(messages) == [{
"role": "tool",
"name": "web_extract",
"tool_call_id": "call-1",
"content": {"ok": True},
}]
class TestToolObservationKeying:
"""Tests for pre/post tool_call observation matching when tool_call_id is absent."""
def _make_mod(self):
sys.modules.pop("plugins.observability.langfuse", None)
return importlib.import_module("plugins.observability.langfuse")
def test_empty_tool_call_id_single_tool_sets_output(self, monkeypatch):
mod = self._make_mod()
obs = object()
state = mod.TraceState(trace_id="t", root_ctx=None, root_span=None)
state.pending_tools_by_name.setdefault("my_tool", []).append(obs)
task_key = mod._trace_key("task-1", "sess-1")
monkeypatch.setitem(mod._TRACE_STATE, task_key, state)
ended = {}
def fake_end(o, *, output=None, metadata=None, **kw):
ended["obs"] = o
ended["output"] = output
monkeypatch.setattr(mod, "_end_observation", fake_end)
mod.on_post_tool_call(
tool_name="my_tool",
args={},
result='{"ok": true}',
task_id="task-1",
session_id="sess-1",
tool_call_id="",
)
assert ended["obs"] is obs
assert ended["output"] == {"ok": True}
assert state.pending_tools_by_name.get("my_tool") is None
def test_threaded_post_calls_preserve_fifo_under_lock(self, monkeypatch):
"""The actual concurrency contract: when 8 threads race to drain
the pending queue, no observation is consumed twice and none is
lost. Validates ``_STATE_LOCK`` discipline, not Python list
semantics."""
import threading
mod = self._make_mod()
n = 8
observations = [object() for _ in range(n)]
state = mod.TraceState(trace_id="t", root_ctx=None, root_span=None)
state.pending_tools_by_name["web_extract"] = list(observations)
task_key = mod._trace_key("task-thr", "sess-thr")
monkeypatch.setitem(mod._TRACE_STATE, task_key, state)
recorded: list = []
lock = threading.Lock()
def fake_end(o, *, output=None, metadata=None, **kw):
with lock:
recorded.append(o)
monkeypatch.setattr(mod, "_end_observation", fake_end)
barrier = threading.Barrier(n)
def worker():
barrier.wait()
mod.on_post_tool_call(
tool_name="web_extract", args={}, result='{"ok": true}',
task_id="task-thr", session_id="sess-thr", tool_call_id="",
)
threads = [threading.Thread(target=worker) for _ in range(n)]
for t in threads:
t.start()
for t in threads:
t.join()
# Every observation was consumed exactly once; queue is empty.
assert len(recorded) == n
assert set(map(id, recorded)) == set(map(id, observations))
assert state.pending_tools_by_name.get("web_extract") is None
def test_explicit_tool_call_id_uses_tools_dict(self, monkeypatch):
"""When tool_call_id is present, pending_tools_by_name is not touched."""
mod = self._make_mod()
obs = object()
state = mod.TraceState(trace_id="t", root_ctx=None, root_span=None)
state.tools["call-99"] = obs
task_key = mod._trace_key("task-1", "sess-1")
monkeypatch.setitem(mod._TRACE_STATE, task_key, state)
ended = {}
def fake_end(o, *, output=None, metadata=None, **kw):
ended["obs"] = o
ended["output"] = output
monkeypatch.setattr(mod, "_end_observation", fake_end)
mod.on_post_tool_call(
tool_name="my_tool", args={}, result='{"status": "done"}',
task_id="task-1", session_id="sess-1", tool_call_id="call-99",
)
assert ended["obs"] is obs
assert ended["output"] == {"status": "done"}
assert not state.tools
class TestUsageFromSanitizedResponse:
"""Regression: ``post_api_request`` delivers ``response`` as a sanitized
dict (no ``.usage`` attribute) plus a separate ``usage`` summary dict. The
post-call handler must read the ``usage`` dict instead of treating the dict
response as a usage-bearing object and dropping all token/cost data."""
def _setup(self, mod, monkeypatch):
# Active client so on_post_llm_call does not early-return.
monkeypatch.setattr(mod, "_get_langfuse", lambda: object())
observation = object()
state = mod.TraceState(trace_id="trace-1", root_ctx=None, root_span=None)
state.generations[mod._request_key(1)] = observation
monkeypatch.setitem(mod._TRACE_STATE, mod._trace_key("task-1", "session-1"), state)
captured = {}
def fake_end_observation(obs, *, output=None, metadata=None, usage_details=None, cost_details=None):
captured["usage_details"] = usage_details
monkeypatch.setattr(mod, "_end_observation", fake_end_observation)
return captured
def test_sanitized_dict_response_uses_usage_dict(self, monkeypatch):
sys.modules.pop("plugins.observability.langfuse", None)
mod = importlib.import_module("plugins.observability.langfuse")
captured = self._setup(mod, monkeypatch)
# A plain dict has no ``.usage`` attribute — mirrors post_api_request.
mod.on_post_llm_call(
task_id="task-1",
session_id="session-1",
api_call_count=1,
model="gemini-3-flash-preview",
response={"model": "gemini-3-flash-preview", "usage": {"input_tokens": 100, "output_tokens": 20}},
usage={"input_tokens": 100, "output_tokens": 20},
assistant_content_chars=42,
)
# Before the fix the dict response shadowed the usage dict and tokens
# were lost (usage_details == {}).
assert captured["usage_details"] == {"input": 100, "output": 20}
def test_real_response_object_with_usage_still_used(self, monkeypatch):
sys.modules.pop("plugins.observability.langfuse", None)
mod = importlib.import_module("plugins.observability.langfuse")
captured = self._setup(mod, monkeypatch)
# A response object that genuinely carries usage must still take the
# response-object path (post_llm_call / legacy behavior).
seen = {}
def fake_usage_and_cost(resp, **_):
seen["resp"] = resp
return {"input": 7, "output": 3}, {}
monkeypatch.setattr(mod, "_usage_and_cost", fake_usage_and_cost)
class _Resp:
usage = {"prompt_tokens": 7, "completion_tokens": 3}
resp = _Resp()
mod.on_post_llm_call(
task_id="task-1",
session_id="session-1",
api_call_count=1,
model="gemini-3-flash-preview",
response=resp,
usage={"input_tokens": 999, "output_tokens": 999},
assistant_content_chars=42,
)
assert seen["resp"] is resp
assert captured["usage_details"] == {"input": 7, "output": 3}
# ---------------------------------------------------------------------------
# Model attribution: wire truth over stale agent attribute
# ---------------------------------------------------------------------------
class TestModelAttribution:
def _fresh_plugin(self):
sys.modules.pop("plugins.observability.langfuse", None)
return importlib.import_module("plugins.observability.langfuse")
def _client_capturing_generations(self, gens):
class _Gen:
def update(self, **kw): pass
def end(self, **kw): pass
class _Span:
def update(self, **kw): pass
def end(self, **kw): pass
def set_trace_io(self, **kw): pass
def start_observation(self, **kw):
gens.append(kw)
return _Gen()
class _RootCM:
def __enter__(self): return _Span()
def __exit__(self, *exc): return False
class _Client:
def create_trace_id(self, seed=None): return "t"
def start_as_current_observation(self, **kw): return _RootCM()
def flush(self): pass
return _Client()
def test_pre_api_request_prefers_request_body_model(self, monkeypatch):
"""Agent attribute says old model; request body says the switched one."""
mod = self._fresh_plugin()
gens: list = []
monkeypatch.setattr(mod, "_get_langfuse", lambda: self._client_capturing_generations(gens))
mod._TRACE_STATE.clear()
mod.on_pre_llm_request(
task_id="t", session_id="s", turn_id="s:t:turn1",
api_call_count=1,
model="old-model-attr",
provider="openrouter",
request_messages=[{"role": "user", "content": "hi"}],
request={"body": {"model": "switched/new-model"}},
)
assert gens, "no generation started"
assert gens[0]["model"] == "switched/new-model"
def test_pre_api_request_falls_back_to_attr_without_body_model(self, monkeypatch):
mod = self._fresh_plugin()
gens: list = []
monkeypatch.setattr(mod, "_get_langfuse", lambda: self._client_capturing_generations(gens))
mod._TRACE_STATE.clear()
mod.on_pre_llm_request(
task_id="t", session_id="s", turn_id="s:t:turn2",
api_call_count=1,
model="attr-model",
request_messages=[{"role": "user", "content": "hi"}],
request={"body": {}},
)
assert gens[0]["model"] == "attr-model"
def test_post_api_request_uses_response_model_for_cost(self, monkeypatch):
"""Cost estimation must key off the model that actually served."""
mod = self._fresh_plugin()
monkeypatch.setattr(mod, "_get_langfuse", lambda: object())
mod._TRACE_STATE.clear()
seen = {}
def fake_usage_and_cost(response, *, provider, api_mode, model, base_url):
seen["model"] = model
return {"input": 1, "output": 1}, {}
monkeypatch.setattr(mod, "_usage_and_cost", fake_usage_and_cost)
class _Gen:
def update(self, **kw): pass
def end(self, **kw): pass
class _Root:
def update(self, **kw): pass
def end(self, **kw): pass
def set_trace_io(self, **kw): pass
turn_id = "s:t:turn3"
key = mod._trace_key("t", "s", turn_id=turn_id)
state = mod.TraceState(trace_id="x", root_ctx=None, root_span=_Root())
state.generations["1"] = _Gen()
mod._TRACE_STATE[key] = state
class _Resp:
usage = {"prompt_tokens": 1, "completion_tokens": 1}
mod.on_post_llm_call(
task_id="t", session_id="s", turn_id=turn_id, api_call_count=1,
model="stale-attr-model",
response_model="actual/served-model",
response=_Resp(),
assistant_content_chars=2,
)
assert seen["model"] == "actual/served-model"
# ---------------------------------------------------------------------------
# Cost total: explicit "total" alongside the per-type breakdown
# ---------------------------------------------------------------------------
class TestCostTotal:
"""Langfuse ingests per-type ``cost_details`` keys but does not derive
``calculatedTotalCost`` from them. Without an explicit ``total`` the
dashboard reads 0 for every priced generation."""
def _fresh_plugin(self):
sys.modules.pop("plugins.observability.langfuse", None)
return importlib.import_module("plugins.observability.langfuse")
def test_response_path_totals_the_breakdown(self):
mod = self._fresh_plugin()
class _Usage:
input_tokens = 1000
output_tokens = 500
cache_read_input_tokens = 2000
cache_creation_input_tokens = 0
class _Resp:
usage = _Usage()
_, cost_details = mod._usage_and_cost(
_Resp(),
provider="anthropic",
api_mode="anthropic_messages",
model="claude-sonnet-4-6",
base_url="",
)
assert cost_details["total"] == pytest.approx(0.0111)
components = {k: v for k, v in cost_details.items() if k != "total"}
assert components
assert cost_details["total"] == pytest.approx(sum(components.values()))
def test_usage_summary_path_totals_the_breakdown(self, monkeypatch):
mod = self._fresh_plugin()
monkeypatch.setattr(mod, "_get_langfuse", lambda: object())
state = mod.TraceState(trace_id="trace-1", root_ctx=None, root_span=None)
state.generations[mod._request_key(1)] = object()
monkeypatch.setitem(mod._TRACE_STATE, mod._trace_key("task-1", "session-1"), state)
captured = {}
def fake_end_observation(obs, *, output=None, metadata=None, usage_details=None, cost_details=None):
captured["cost_details"] = cost_details
monkeypatch.setattr(mod, "_end_observation", fake_end_observation)
# A dict response has no ``.usage``, so the handler takes the
# usage-summary path rather than the response-object path.
mod.on_post_llm_call(
task_id="task-1",
session_id="session-1",
api_call_count=1,
model="claude-sonnet-4-6",
provider="anthropic",
response={"model": "claude-sonnet-4-6"},
usage={"input_tokens": 1000, "output_tokens": 500},
assistant_content_chars=42,
)
cost_details = captured["cost_details"]
components = {k: v for k, v in cost_details.items() if k != "total"}
assert components
assert cost_details["total"] == pytest.approx(sum(components.values()))
def test_priced_model_with_no_tokens_reports_no_total(self):
mod = self._fresh_plugin()
class _Usage:
input_tokens = 0
output_tokens = 0
class _Resp:
usage = _Usage()
_, cost_details = mod._usage_and_cost(
_Resp(),
provider="anthropic",
api_mode="anthropic_messages",
model="claude-sonnet-4-6",
base_url="",
)
# A priced model that billed nothing writes no per-type keys, so
# summing them must not invent a 0.0 total on an empty breakdown.
assert cost_details == {}
# ---------------------------------------------------------------------------
# Capture modes: metadata | sanitized | full (HERMES_LANGFUSE_CAPTURE)
# ---------------------------------------------------------------------------
class TestCaptureModes:
def _fresh_plugin(self):
sys.modules.pop("plugins.observability.langfuse", None)
return importlib.import_module("plugins.observability.langfuse")
def test_default_mode_is_sanitized(self, monkeypatch):
mod = self._fresh_plugin()
monkeypatch.delenv("HERMES_LANGFUSE_CAPTURE", raising=False)
assert mod._capture_mode() == "sanitized"
def test_invalid_mode_falls_back_and_warns_once(self, monkeypatch, caplog):
mod = self._fresh_plugin()
monkeypatch.setenv("HERMES_LANGFUSE_CAPTURE", "everything")
with caplog.at_level(logging.WARNING):
assert mod._capture_mode() == "sanitized"
assert mod._capture_mode() == "sanitized"
warnings = [r for r in caplog.records if "HERMES_LANGFUSE_CAPTURE" in r.getMessage()]
assert len(warnings) == 1
def test_metadata_mode_omits_content(self, monkeypatch):
mod = self._fresh_plugin()
monkeypatch.setenv("HERMES_LANGFUSE_CAPTURE", "metadata")
out = mod._capture_content("top secret prompt text")
assert out == {"omitted": True, "type": "text", "chars": 22}
obj = mod._capture_content({"password": "hunter22", "path": "/x"})
assert obj["omitted"] is True
assert set(obj["keys"]) == {"password", "path"}
assert "hunter22" not in str(obj)
def test_metadata_mode_message_serialization_keeps_roles(self, monkeypatch):
mod = self._fresh_plugin()
monkeypatch.setenv("HERMES_LANGFUSE_CAPTURE", "metadata")
msgs = mod._serialize_messages([
{"role": "user", "content": "my ssn is 123-45-6789"},
{"role": "assistant", "content": "noted"},
])
assert [m["role"] for m in msgs] == ["user", "assistant"]
assert all(isinstance(m["content"], dict) and m["content"]["omitted"] for m in msgs)
assert "6789" not in str(msgs)
def test_sanitized_mode_redacts_secrets(self, monkeypatch):
mod = self._fresh_plugin()
monkeypatch.setenv("HERMES_LANGFUSE_CAPTURE", "sanitized")
samples = {
"openai": "here sk-" + "a" * 20 + " done",
"anthropic": "key sk-ant-" + "a" * 20 + " x",
"github": "tok ghp_" + "a" * 36,
"aws": "AKIA" + "A" * 16,
"langfuse": "pk-lf-" + "a" * 20,
"bearer": "Authorization: Bearer " + "a" * 20,
"assignment": 'api_key="supersecretvalue"',
}
for name, text in samples.items():
out = mod._capture_content(text)
# redact_sensitive_text masks secrets (e.g. "sk-aaa...aaaa") or
# replaces them with "«redacted:...»" sentinels — check that the
# original secret substring is gone, not for a specific marker.
assert text != out, f"{name} not redacted: {out!r}"
def test_sanitized_mode_redacts_before_truncation(self, monkeypatch):
mod = self._fresh_plugin()
monkeypatch.setenv("HERMES_LANGFUSE_CAPTURE", "sanitized")
secret = "sk-" + "z" * 40
text = "x" * 100 + " " + secret + " " + "y" * 100
out = mod._truncate_text(text, 120)
assert "z" * 10 not in out
assert text != out, "secret was not redacted before truncation"
def test_sanitized_mode_keeps_ordinary_text(self, monkeypatch):
mod = self._fresh_plugin()
monkeypatch.setenv("HERMES_LANGFUSE_CAPTURE", "sanitized")
text = "refactor the memory manager to emit spans"
assert mod._capture_content(text) == text
def test_full_mode_keeps_secret_shaped_text(self, monkeypatch):
mod = self._fresh_plugin()
monkeypatch.setenv("HERMES_LANGFUSE_CAPTURE", "full")
text = "here sk-abcdefghijklmnop1234 done"
assert mod._capture_content(text) == text
def test_capture_mode_recorded_in_trace_metadata(self, monkeypatch):
mod = self._fresh_plugin()
monkeypatch.setenv("HERMES_LANGFUSE_CAPTURE", "metadata")
seen = {}
class _Span:
def update(self, **kw): pass
def end(self, **kw): pass
def set_trace_io(self, **kw): pass
def start_observation(self, **kw): return _Span()
class _RootCM:
def __enter__(self): return _Span()
def __exit__(self, *exc): return False
class _Client:
def create_trace_id(self, seed=None): return "t1"
def start_as_current_observation(self, **kw):
seen.update(kw)
return _RootCM()
state = mod._start_root_trace(
"k", task_id="t", session_id="s", platform="cli", provider="p",
model="m", api_mode="chat", messages=[{"role": "user", "content": "hi"}],
client=_Client(),
)
assert seen["metadata"]["capture_mode"] == "metadata"
assert state is not None
# ---------------------------------------------------------------------------
# api_request_error hook
# ---------------------------------------------------------------------------
class TestApiRequestErrorHook:
def _fresh_plugin(self):
sys.modules.pop("plugins.observability.langfuse", None)
return importlib.import_module("plugins.observability.langfuse")
def _seed_state(self, mod, task_key, gen_key="1"):
class _Gen:
def __init__(self):
self.updates = []
self.ended = False
def update(self, **kw):
self.updates.append(kw)
def end(self, **kw):
self.ended = True
class _Root:
def __init__(self):
self.ended = False
def update(self, **kw): pass
def end(self, **kw): self.ended = True
def set_trace_io(self, **kw): pass
gen = _Gen()
root = _Root()
state = mod.TraceState(trace_id="t", root_ctx=None, root_span=root)
state.generations[gen_key] = gen
mod._TRACE_STATE[task_key] = state
return gen, root
def test_retryable_error_closes_generation_keeps_turn(self, monkeypatch):
mod = self._fresh_plugin()
monkeypatch.setattr(mod, "_get_langfuse", lambda: object())
mod._TRACE_STATE.clear()
turn_id = "s:t:turn1"
task_key = mod._trace_key("t", "s", turn_id=turn_id)
gen, root = self._seed_state(mod, task_key)
mod.on_api_request_error(
task_id="t", session_id="s", api_call_count=1,
turn_id=turn_id,
status_code=429, retryable=True, retry_count=1, max_retries=3,
error={"type": "RateLimitError", "message": "slow down"},
)
assert gen.ended is True
assert any(u.get("level") == "ERROR" for u in gen.updates)
# error metadata landed
meta = [u["metadata"] for u in gen.updates if "metadata" in u]
assert meta and meta[0]["status_code"] == 429
# turn stays open for the retry
assert task_key in mod._TRACE_STATE
assert root.ended is False
def test_terminal_error_finishes_turn(self, monkeypatch):
mod = self._fresh_plugin()
monkeypatch.setattr(mod, "_get_langfuse", lambda: type("C", (), {"flush": lambda self: None})())
mod._TRACE_STATE.clear()
turn_id = "s:t:turn2"
task_key = mod._trace_key("t", "s", turn_id=turn_id)
gen, root = self._seed_state(mod, task_key)
mod.on_api_request_error(
task_id="t", session_id="s", api_call_count=1,
turn_id=turn_id,
status_code=401, retryable=False,
error={"type": "AuthenticationError", "message": "bad key"},
)
assert gen.ended is True
assert task_key not in mod._TRACE_STATE
assert root.ended is True
def test_error_hook_noops_without_state(self, monkeypatch):
mod = self._fresh_plugin()
monkeypatch.setattr(mod, "_get_langfuse", lambda: object())
mod._TRACE_STATE.clear()
# Must not raise
mod.on_api_request_error(
task_id="t", session_id="s", api_call_count=1,
error={"type": "X", "message": "y"}, retryable=False,
)
def test_error_message_respects_capture_mode(self, monkeypatch):
mod = self._fresh_plugin()
monkeypatch.setenv("HERMES_LANGFUSE_CAPTURE", "metadata")
monkeypatch.setattr(mod, "_get_langfuse", lambda: object())
mod._TRACE_STATE.clear()
turn_id = "s:t:turn3"
task_key = mod._trace_key("t", "s", turn_id=turn_id)
gen, _root = self._seed_state(mod, task_key)
mod.on_api_request_error(
task_id="t", session_id="s", api_call_count=1, turn_id=turn_id,
retryable=True,
error={"type": "APIError", "message": "secret prompt echo sk-abc"},
)
meta = [u["metadata"] for u in gen.updates if "metadata" in u][0]
assert isinstance(meta["error_message"], dict)
assert meta["error_message"]["omitted"] is True
# ---------------------------------------------------------------------------
# on_session_finalize hook
# ---------------------------------------------------------------------------
class TestSessionFinalizeHook:
def _fresh_plugin(self):
sys.modules.pop("plugins.observability.langfuse", None)
return importlib.import_module("plugins.observability.langfuse")
def _client(self, flushes):
class _Client:
def flush(self):
flushes.append(1)
return _Client()
def _state(self, mod):
class _Root:
def __init__(self):
self.ended = False
def update(self, **kw): pass
def end(self, **kw): self.ended = True
def set_trace_io(self, **kw): pass
root = _Root()
return mod.TraceState(trace_id="t", root_ctx=None, root_span=root), root
def test_finalize_closes_matching_session_traces(self, monkeypatch):
mod = self._fresh_plugin()
flushes = []
client = self._client(flushes)
monkeypatch.setattr(mod, "_LANGFUSE_CLIENT", client)
monkeypatch.setattr(mod, "_get_langfuse", lambda: client)
mod._TRACE_STATE.clear()
s1, r1 = self._state(mod)
s2, r2 = self._state(mod)
mod._TRACE_STATE["session:sess-a:turn:1"] = s1
mod._TRACE_STATE["session:sess-b:turn:1"] = s2
mod.on_session_finalize(session_id="sess-a")
assert "session:sess-a:turn:1" not in mod._TRACE_STATE
assert "session:sess-b:turn:1" in mod._TRACE_STATE
assert r1.ended is True
assert r2.ended is False
assert flushes # flushed at least once
def test_finalize_without_session_closes_all(self, monkeypatch):
mod = self._fresh_plugin()
flushes = []
client = self._client(flushes)
monkeypatch.setattr(mod, "_LANGFUSE_CLIENT", client)
monkeypatch.setattr(mod, "_get_langfuse", lambda: client)
mod._TRACE_STATE.clear()
s1, r1 = self._state(mod)
mod._TRACE_STATE["session:sess-x:turn:1"] = s1
mod.on_session_finalize()
assert not mod._TRACE_STATE
assert r1.ended is True
def test_finalize_noop_when_client_never_initialized(self):
mod = self._fresh_plugin()
mod._TRACE_STATE.clear()
# _LANGFUSE_CLIENT is None on a fresh module; must not raise or init.
mod.on_session_finalize(session_id="whatever")
def test_finalize_shuts_down_client_on_process_exit(self, monkeypatch):
"""reason="shutdown" must call client.shutdown() while the interpreter
is alive, so the SDK's own atexit handler (which runs during
interpreter finalization, after opentelemetry.trace.Span is torn
down) becomes a no-op instead of raising the "isinstance() arg 2
must be a type" TypeError on quit."""
mod = self._fresh_plugin()
events = []
class _Client:
def flush(self):
events.append("flush")
def shutdown(self):
events.append("shutdown")
client = _Client()
monkeypatch.setattr(mod, "_LANGFUSE_CLIENT", client)
monkeypatch.setattr(mod, "_get_langfuse", lambda: client)
mod._TRACE_STATE.clear()
mod.on_session_finalize(session_id="sess-a", reason="shutdown")
assert "shutdown" in events
assert events.index("flush") < events.index("shutdown")
def test_finalize_keeps_client_alive_on_session_rotation(self, monkeypatch):
"""/new, /reset, and gateway session expiry finalize the session but
the process lives on — the cached client must NOT be shut down or
later sessions silently stop exporting."""
mod = self._fresh_plugin()
events = []
class _Client:
def flush(self):
events.append("flush")
def shutdown(self):
events.append("shutdown")
client = _Client()
monkeypatch.setattr(mod, "_LANGFUSE_CLIENT", client)
monkeypatch.setattr(mod, "_get_langfuse", lambda: client)
mod._TRACE_STATE.clear()
for reason in ("session_boundary", "new_session", "session_expired", ""):
mod.on_session_finalize(session_id="sess-a", reason=reason)
assert "shutdown" not in events
assert "flush" in events
# ---------------------------------------------------------------------------
# Subagent tracing: delegated children as spans under the parent turn
# ---------------------------------------------------------------------------
class TestSubagentTracing:
"""``tools/delegate_tool.py`` emits subagent_start/subagent_stop. The
payloads carry ``parent_turn_id`` but no ``task_id``, so the parent trace
must be resolved by turn id rather than by rebuilding the scope key."""
def _fresh_plugin(self):
sys.modules.pop("plugins.observability.langfuse", None)
return importlib.import_module("plugins.observability.langfuse")
def _state(self, mod, monkeypatch, key, spans):
class _Obs:
def __init__(self, kw):
self.kw = kw
self.ended = False
self.updates = {}
def update(self, **kw):
self.updates.update(kw)
def end(self, **kw):
self.ended = True
class _Root:
def start_observation(self, **kw):
obs = _Obs(kw)
spans.append(obs)
return obs
monkeypatch.setattr(mod, "_get_langfuse", lambda: object())
state = mod.TraceState(trace_id="trace-1", root_ctx=None, root_span=_Root())
monkeypatch.setitem(mod._TRACE_STATE, key, state)
return state
def test_start_attaches_span_despite_task_scoped_key(self, monkeypatch):
mod = self._fresh_plugin()
spans = []
# Key minted by the LLM hooks with a task id — a naive rebuild from
# session_id alone would not match this.
state = self._state(mod, monkeypatch, "task:task-9:turn:turn-7", spans)
mod.on_subagent_start(
parent_session_id="sess-1",
parent_turn_id="turn-7",
child_session_id="child-sess-1",
child_subagent_id="sub-1",
child_role="researcher",
child_goal="find the thing",
)
assert len(spans) == 1
assert spans[0].kw["name"] == "Subagent: researcher"
assert spans[0].kw["metadata"]["child_subagent_id"] == "sub-1"
assert "child-sess-1" in state.subagents
def test_stop_ends_span_and_records_outcome(self, monkeypatch):
mod = self._fresh_plugin()
spans = []
state = self._state(mod, monkeypatch, "task:task-9:turn:turn-7", spans)
mod.on_subagent_start(
parent_session_id="sess-1", parent_turn_id="turn-7",
child_session_id="child-sess-1", child_role="researcher",
child_goal="find the thing",
)
mod.on_subagent_stop(
parent_session_id="sess-1", parent_turn_id="turn-7",
child_session_id="child-sess-1", child_role="researcher",
child_summary="found it", child_status="ok",
tool_call_history=[{"name": "read_file"}, {"name": "grep"}],
duration_ms=1234,
)
assert spans[0].ended is True
assert spans[0].updates["metadata"]["status"] == "ok"
assert spans[0].updates["metadata"]["tool_call_count"] == 2
assert spans[0].updates["metadata"]["duration_ms"] == 1234
# Popped so a repeated stop cannot double-end the span.
assert not state.subagents
def test_unknown_turn_is_a_noop(self, monkeypatch):
mod = self._fresh_plugin()
spans = []
self._state(mod, monkeypatch, "task:task-9:turn:turn-7", spans)
mod.on_subagent_start(
parent_turn_id="turn-does-not-exist",
child_session_id="child-sess-1", child_role="researcher",
)
mod.on_subagent_stop(
parent_turn_id="turn-does-not-exist",
child_session_id="child-sess-1",
)
assert spans == []
def test_start_without_child_session_is_a_noop(self, monkeypatch):
mod = self._fresh_plugin()
spans = []
self._state(mod, monkeypatch, "task:task-9:turn:turn-7", spans)
# subagent_stop keys on child_session_id, so a start without one could
# never be matched and must not open an unclosable span.
mod.on_subagent_start(
parent_turn_id="turn-7", child_session_id=None, child_role="researcher",
)
assert spans == []
# ---------------------------------------------------------------------------
# MoA fan-out: one generation per advisor, priced at the advisor's own model
# ---------------------------------------------------------------------------
class TestMoAReferenceGenerations:
"""MoA returns only the aggregator's response, so without per-advisor
generations the whole fan-out collapses into one line priced at the
aggregator's model. Advisors routinely run on a different provider."""
def _fresh_plugin(self):
sys.modules.pop("plugins.observability.langfuse", None)
return importlib.import_module("plugins.observability.langfuse")
def _state(self, mod, monkeypatch, gens):
class _Obs:
def __init__(self, kw):
self.kw = kw
self.updates = {}
self.ended = False
def update(self, **kw):
self.updates.update(kw)
def end(self, **kw):
self.ended = True
class _Root:
def start_observation(self, **kw):
obs = _Obs(kw)
gens.append(obs)
return obs
monkeypatch.setattr(mod, "_get_langfuse", lambda: object())
return mod.TraceState(trace_id="t", root_ctx=None, root_span=_Root())
def _refs(self):
return [
{
"label": "anthropic:claude-sonnet-4-6",
"model": "claude-sonnet-4-6",
"provider": "anthropic",
"usage": {"input_tokens": 100, "output_tokens": 50},
"cost_usd": 0.001,
"cost_status": "ok",
"cost_source": "pricing_table",
},
{
"label": "openai:gpt-5",
"model": "gpt-5",
"provider": "openai",
"usage": {"input_tokens": 80, "output_tokens": 40, "reasoning_tokens": 10},
"cost_usd": 0.002,
},
]
def test_one_generation_per_advisor_with_own_model_and_cost(self, monkeypatch):
mod = self._fresh_plugin()
gens = []
state = self._state(mod, monkeypatch, gens)
mod._emit_moa_reference_generations(state, client=object(), references=self._refs())
assert len(gens) == 2
assert gens[0].kw["model"] == "claude-sonnet-4-6"
assert gens[1].kw["model"] == "gpt-5"
# Each advisor's dollars, not the aggregator's rate applied to all.
assert gens[0].updates["cost_details"]["total"] == pytest.approx(0.001)
assert gens[1].updates["cost_details"]["total"] == pytest.approx(0.002)
assert gens[0].updates["usage_details"] == {"input": 100, "output": 50}
assert gens[1].updates["usage_details"]["reasoning_tokens"] == 10
assert all(g.ended for g in gens)
def test_repeat_emit_is_deduped_within_a_turn(self, monkeypatch):
mod = self._fresh_plugin()
gens = []
state = self._state(mod, monkeypatch, gens)
# The MoA client holds its last fan-out until the next one, so a
# tool-loop turn delivers the same references on every API call.
refs = self._refs()
mod._emit_moa_reference_generations(state, client=object(), references=refs)
mod._emit_moa_reference_generations(state, client=object(), references=refs)
mod._emit_moa_reference_generations(state, client=object(), references=list(refs))
assert len(gens) == 2
def test_a_new_fanout_emits_again(self, monkeypatch):
mod = self._fresh_plugin()
gens = []
state = self._state(mod, monkeypatch, gens)
mod._emit_moa_reference_generations(state, client=object(), references=self._refs())
second = self._refs()
second[0]["usage"]["output_tokens"] = 999
mod._emit_moa_reference_generations(state, client=object(), references=second)
assert len(gens) == 4
def test_non_moa_turn_emits_nothing(self, monkeypatch):
mod = self._fresh_plugin()
gens = []
state = self._state(mod, monkeypatch, gens)
for value in (None, [], "not-a-list", [None, "junk"]):
mod._emit_moa_reference_generations(state, client=object(), references=value)
assert gens == []
class TestAtexitFinalization(TestTurnTraceIsolation):
"""Short-lived processes (kanban workers, `hermes chat -q`, cron) can exit
with tool calls still queued — the root span never ends and the backend
shows an anonymous trace (no name/session/metadata). _finalize_all_traces
(registered atexit after client construction) must end every open root."""
def test_finalize_all_ends_open_roots_and_clears_state(self, monkeypatch):
mod = self._fresh_plugin()
started: list = []
ended: list = []
client = self._fake_client(started)
monkeypatch.setattr(mod, "_get_langfuse", lambda: client)
monkeypatch.setattr(
mod, "_end_observation", lambda obs, **k: ended.append(obs)
)
mod._TRACE_STATE.clear()
# Three worker-style turns that never finalize (tool calls pending).
for n in range(3):
self._run_turn(mod, session=f"worker-{n}", turn_n=0, finalize=False)
assert len(mod._TRACE_STATE) == 3
root_ends: list = []
for state in mod._TRACE_STATE.values():
real_end = state.root_span.end
state.root_span.end = lambda *a, _r=real_end, **k: root_ends.append(1)
mod._finalize_all_traces()
assert len(root_ends) == 3, "every open root span must be ended"
assert mod._TRACE_STATE == {}, "state must be drained"
# Idempotent: a second call (SDK/atexit re-entry) is a no-op.
mod._finalize_all_traces()
assert len(root_ends) == 3
def test_atexit_hook_is_registered_on_client_init(self, monkeypatch):
mod = self._fresh_plugin()
registered: list = []
import atexit as _atexit
monkeypatch.setattr(mod, "Langfuse", lambda **kw: object())
monkeypatch.setattr(
_atexit, "register", lambda fn, *a, **k: registered.append(fn)
)
monkeypatch.setenv("HERMES_LANGFUSE_PUBLIC_KEY", "pk-lf-0123456789abcdef")
monkeypatch.setenv("HERMES_LANGFUSE_SECRET_KEY", "sk-lf-0123456789abcdef")
mod._LANGFUSE_CLIENT = None
assert mod._get_langfuse() is not None
assert mod._finalize_all_traces in registered
class TestSystemPromptInGenerationInput:
"""The generation input must carry the system prompt even for providers
that move it out of ``messages``: Anthropic Messages (``system`` kwarg)
and the Responses/Codex API (``instructions``). Hermes forwards it to
hooks as ``system_prompt``; the plugin prepends a ``role: system`` entry.
Regression for the trace gap discussed in PR #32175 (Anthropic) and its
Codex sibling: without this, hosted traces show conversations without the
agent's instructions, skills, and memory."""
def _make_mod(self):
sys.modules.pop("plugins.observability.langfuse", None)
return importlib.import_module("plugins.observability.langfuse")
def _capture_generation(self, mod, monkeypatch):
"""Route on_pre_llm_request into a seeded TraceState and record the
generation observation kwargs."""
captured = {}
monkeypatch.setattr(mod, "_get_langfuse", lambda: object())
state = mod.TraceState(trace_id="t", root_ctx=None, root_span=None)
task_key = mod._trace_key("task-1", "sess-1")
monkeypatch.setitem(mod._TRACE_STATE, task_key, state)
def fake_child(state_, **kw):
captured["input"] = kw.get("input_value")
captured["metadata"] = kw.get("metadata")
return object()
monkeypatch.setattr(mod, "_start_child_observation", fake_child)
return captured
def _fire(self, mod, *, request_messages, system_prompt=None):
kwargs = dict(
task_id="task-1",
session_id="sess-1",
model="m",
provider="p",
api_mode="codex_responses",
api_call_count=1,
request_messages=request_messages,
)
if system_prompt is not None:
kwargs["system_prompt"] = system_prompt
mod.on_pre_llm_request(**kwargs)
def test_string_system_prompt_prepended(self, monkeypatch):
mod = self._make_mod()
captured = self._capture_generation(mod, monkeypatch)
self._fire(
mod,
request_messages=[{"role": "user", "content": "hi"}],
system_prompt="You are Hermes.",
)
assert captured["input"][0]["role"] == "system"
assert captured["input"][0]["content"] == "You are Hermes."
assert captured["input"][1]["role"] == "user"
def test_anthropic_block_list_flattened(self, monkeypatch):
"""Anthropic OAuth mode sends ``system`` as content blocks (with
cache_control); the trace should carry the readable text."""
mod = self._make_mod()
captured = self._capture_generation(mod, monkeypatch)
blocks = [
{"type": "text", "text": "part one", "cache_control": {"type": "ephemeral"}},
{"type": "text", "text": "part two"},
]
self._fire(
mod,
request_messages=[{"role": "user", "content": "hi"}],
system_prompt=blocks,
)
first = captured["input"][0]
assert first["role"] == "system"
assert "part one" in first["content"]
assert "part two" in first["content"]
def test_no_duplicate_when_messages_already_carry_system(self, monkeypatch):
"""chat_completions keeps system in messages[0]; forwarding
system_prompt as well must not produce two system entries."""
mod = self._make_mod()
captured = self._capture_generation(mod, monkeypatch)
self._fire(
mod,
request_messages=[
{"role": "system", "content": "You are Hermes."},
{"role": "user", "content": "hi"},
],
system_prompt="You are Hermes.",
)
roles = [m["role"] for m in captured["input"]]
assert roles.count("system") == 1
assert roles[0] == "system"
def test_absent_system_prompt_keeps_previous_shape(self, monkeypatch):
mod = self._make_mod()
captured = self._capture_generation(mod, monkeypatch)
self._fire(mod, request_messages=[{"role": "user", "content": "hi"}])
assert captured["input"][0]["role"] == "user"
assert "system_prompt_chars" not in (captured["metadata"] or {})
def test_system_survives_serialization_window(self, monkeypatch):
"""_serialize_messages keeps only the last 12 messages; the system
prompt must be prepended after windowing so long conversations
never drop it."""
mod = self._make_mod()
captured = self._capture_generation(mod, monkeypatch)
many = [
{"role": "user" if i % 2 == 0 else "assistant", "content": f"m{i}"}
for i in range(30)
]
self._fire(mod, request_messages=many, system_prompt="SYS")
assert captured["input"][0]["role"] == "system"
# window (12) + prepended system
assert len(captured["input"]) == 13
def test_metadata_records_chars(self, monkeypatch):
mod = self._make_mod()
captured = self._capture_generation(mod, monkeypatch)
self._fire(
mod,
request_messages=[{"role": "user", "content": "hi"}],
system_prompt="You are Hermes.",
)
assert captured["metadata"]["system_prompt_chars"] == len("You are Hermes.")
class TestSystemPromptCrossesHookBoundary:
"""End-to-end across the hook seam with real transport-built kwargs —
the regression coverage PR #32175's review asked for: verify the
provider-specific request shape (Anthropic ``system`` kwarg, Codex
``instructions``) actually reaches the Langfuse generation input, with
no Hermes internals mocked (only the Langfuse client is faked)."""
def _make_mod(self):
sys.modules.pop("plugins.observability.langfuse", None)
return importlib.import_module("plugins.observability.langfuse")
def _capture_generation(self, mod, monkeypatch):
captured = {}
monkeypatch.setattr(mod, "_get_langfuse", lambda: object())
state = mod.TraceState(trace_id="t", root_ctx=None, root_span=None)
task_key = mod._trace_key("task-1", "sess-1")
monkeypatch.setitem(mod._TRACE_STATE, task_key, state)
def fake_child(state_, **kw):
captured["input"] = kw.get("input_value")
return object()
monkeypatch.setattr(mod, "_start_child_observation", fake_child)
return captured
def _derive_and_fire(self, mod, api_kwargs, api_messages):
"""Mirror agent/conversation_loop.py's pre_api_request emission:
derive request_messages exactly the way the loop does, derive
system_prompt via the loop's helper, and invoke the plugin hook."""
from agent.conversation_loop import _system_prompt_for_hooks
request_messages = api_kwargs.get("messages")
if not isinstance(request_messages, list):
request_messages = api_kwargs.get("input")
if not isinstance(request_messages, list):
request_messages = api_messages
mod.on_pre_llm_request(
task_id="task-1",
session_id="sess-1",
model="m",
provider="p",
api_mode="x",
api_call_count=1,
request_messages=list(request_messages),
system_prompt=_system_prompt_for_hooks(api_kwargs, request_messages),
)
def test_codex_instructions_reach_generation_input(self, monkeypatch):
from agent.transports.codex import ResponsesApiTransport
api_messages = [
{"role": "system", "content": "SYS-CODEX"},
{"role": "user", "content": "hi"},
]
api_kwargs = ResponsesApiTransport().build_kwargs("gpt-x", api_messages, None)
# Premise: the Responses API moves the system prompt out of the input.
assert api_kwargs["instructions"] == "SYS-CODEX"
assert all(i.get("role") != "system" for i in api_kwargs["input"] if isinstance(i, dict))
mod = self._make_mod()
captured = self._capture_generation(mod, monkeypatch)
self._derive_and_fire(mod, api_kwargs, api_messages)
assert captured["input"][0]["role"] == "system"
assert captured["input"][0]["content"] == "SYS-CODEX"
def test_anthropic_system_kwarg_reaches_generation_input(self, monkeypatch):
from agent.transports.anthropic import AnthropicTransport
api_messages = [
{"role": "system", "content": "SYS-ANTHROPIC"},
{"role": "user", "content": "hi"},
]
api_kwargs = AnthropicTransport().build_kwargs(
"claude-x", api_messages, None, max_tokens=64
)
# Premise: the Messages API moves the system prompt to a kwarg.
assert "system" in api_kwargs
assert all(m.get("role") != "system" for m in api_kwargs["messages"])
mod = self._make_mod()
captured = self._capture_generation(mod, monkeypatch)
self._derive_and_fire(mod, api_kwargs, api_messages)
assert captured["input"][0]["role"] == "system"
assert "SYS-ANTHROPIC" in captured["input"][0]["content"]
def test_bedrock_system_kwarg_reaches_generation_input(self, monkeypatch):
from agent.transports.bedrock import BedrockTransport
api_messages = [
{"role": "system", "content": "SYS-BEDROCK"},
{"role": "user", "content": "hi"},
]
api_kwargs = BedrockTransport().build_kwargs(
"anthropic.claude-x", api_messages, None, max_tokens=64
)
# Premise: Bedrock Converse moves system into a separate 'system' kwarg,
# shaped as [{"text": ...}] blocks — no "type" key, unlike Anthropic.
# (The transport may append extra blocks, e.g. cachePoint markers.)
assert {"text": "SYS-BEDROCK"} in api_kwargs["system"]
assert all(m.get("role") != "system" for m in api_kwargs["messages"])
mod = self._make_mod()
captured = self._capture_generation(mod, monkeypatch)
self._derive_and_fire(mod, api_kwargs, api_messages)
assert captured["input"][0]["role"] == "system"
assert "SYS-BEDROCK" in captured["input"][0]["content"]
def test_chat_completions_shape_needs_no_fallback(self, monkeypatch):
"""When system stays in messages[0] (chat_completions), the helper
returns it but the plugin must not duplicate the entry."""
from agent.conversation_loop import _system_prompt_for_hooks
api_kwargs = {
"messages": [
{"role": "system", "content": "SYS-CHAT"},
{"role": "user", "content": "hi"},
]
}
sp = _system_prompt_for_hooks(api_kwargs, api_kwargs["messages"])
assert sp == "SYS-CHAT"
mod = self._make_mod()
captured = self._capture_generation(mod, monkeypatch)
self._derive_and_fire(mod, api_kwargs, api_kwargs["messages"])
roles = [m["role"] for m in captured["input"]]
assert roles.count("system") == 1
class TestFinishTraceUsesUpdateTrace:
"""Regression: SDK v3 has update_trace, not set_trace_io.
Calling the non-existent set_trace_io raised AttributeError inside
_finish_trace's try block and skipped root_span.end(). Generations/tools
still exported, so the Langfuse list showed Observation Levels + Latency
but blank Input/Output columns (no CHAIN root).
"""
def test_finish_ends_root_and_calls_update_trace(self, monkeypatch):
sys.modules.pop("plugins.observability.langfuse", None)
mod = importlib.import_module("plugins.observability.langfuse")
roots: list = []
class _Span:
def __init__(self):
self.ended = False
self.updates = []
self.trace_updates = []
def update(self, **kw):
self.updates.append(kw)
def end(self, **kw):
self.ended = True
def update_trace(self, **kw):
self.trace_updates.append(kw)
def start_observation(self, **kw):
return _Span()
# Deliberately NO set_trace_io — mirrors real LangfuseChain.
class _RootCM:
def __init__(self):
self.span = _Span()
roots.append(self.span)
def __enter__(self):
return self.span
def __exit__(self, *exc):
return False
class _Client:
def create_trace_id(self, seed=None):
return f"trace::{seed}"
def start_as_current_observation(self, **kw):
return _RootCM()
def flush(self):
pass
monkeypatch.setattr(mod, "_get_langfuse", lambda: _Client())
monkeypatch.setattr(mod, "_end_observation", lambda *a, **k: None)
mod._TRACE_STATE.clear()
mod.on_pre_llm_request(
task_id="t1",
session_id="s1",
model="m",
provider="p",
api_mode="chat",
api_call_count=1,
request_messages=[{"role": "user", "content": "hi"}],
turn_id="turn-1",
)
mod.on_post_llm_call(
task_id="t1",
session_id="s1",
model="m",
provider="p",
api_mode="chat",
api_call_count=1,
assistant_content_chars=12,
assistant_tool_call_count=0,
assistant_response="hello world!",
turn_id="turn-1",
)
assert len(roots) == 1
root = roots[0]
assert root.ended is True
assert any("output" in u for u in root.trace_updates)
assert any("output" in u for u in root.updates)
assert mod._TRACE_STATE == {}
def test_finish_still_ends_when_update_trace_raises(self, monkeypatch):
sys.modules.pop("plugins.observability.langfuse", None)
mod = importlib.import_module("plugins.observability.langfuse")
roots: list = []
class _Span:
def __init__(self):
self.ended = False
def update(self, **kw):
pass
def end(self, **kw):
self.ended = True
def update_trace(self, **kw):
raise RuntimeError("simulated update_trace failure")
def start_observation(self, **kw):
return _Span()
class _RootCM:
def __init__(self):
self.span = _Span()
roots.append(self.span)
def __enter__(self):
return self.span
def __exit__(self, *exc):
return False
class _Client:
def create_trace_id(self, seed=None):
return f"trace::{seed}"
def start_as_current_observation(self, **kw):
return _RootCM()
def flush(self):
pass
monkeypatch.setattr(mod, "_get_langfuse", lambda: _Client())
monkeypatch.setattr(mod, "_end_observation", lambda *a, **k: None)
mod._TRACE_STATE.clear()
mod.on_pre_llm_request(
task_id="t1",
session_id="s1",
model="m",
provider="p",
api_mode="chat",
api_call_count=1,
request_messages=[{"role": "user", "content": "hi"}],
turn_id="turn-1",
)
mod.on_post_llm_call(
task_id="t1",
session_id="s1",
model="m",
provider="p",
api_mode="chat",
api_call_count=1,
assistant_content_chars=5,
assistant_tool_call_count=0,
assistant_response="done",
turn_id="turn-1",
)
assert roots[0].ended is True
assert mod._TRACE_STATE == {}
class TestCanonicalCostExport:
"""Both supported response paths must export the same complete cost."""
@staticmethod
def _response(input_tokens, output_tokens, cache_read=0, cache_write=0):
cache_details = SimpleNamespace(
cached_tokens=cache_read,
cache_write_tokens=cache_write,
)
usage = SimpleNamespace(
# Anthropic response shape.
input_tokens=input_tokens,
output_tokens=output_tokens,
cache_read_input_tokens=cache_read,
cache_creation_input_tokens=cache_write,
# OpenAI chat response shape used by the included-route case.
prompt_tokens=input_tokens + cache_read + cache_write,
completion_tokens=output_tokens,
prompt_tokens_details=cache_details,
)
return SimpleNamespace(usage=usage)
@staticmethod
def _summary(input_tokens, output_tokens, cache_read=0, cache_write=0, request_count=1):
return {
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"cache_read_tokens": cache_read,
"cache_write_tokens": cache_write,
"reasoning_tokens": 0,
"request_count": request_count,
}
@staticmethod
def _capture_summary_path(mod, monkeypatch, usage, *, provider, model, api_mode):
monkeypatch.setattr(mod, "_get_langfuse", lambda: object())
observation = object()
state = mod.TraceState(trace_id="trace-cost", root_ctx=None, root_span=None)
state.generations[mod._request_key(1)] = observation
task_key = mod._trace_key("task-cost", "session-cost")
monkeypatch.setitem(mod._TRACE_STATE, task_key, state)
captured = {}
def fake_end_observation(
obs,
*,
output=None,
metadata=None,
usage_details=None,
cost_details=None,
):
captured["usage_details"] = usage_details
captured["cost_details"] = cost_details
monkeypatch.setattr(mod, "_end_observation", fake_end_observation)
mod.on_post_llm_call(
task_id="task-cost",
session_id="session-cost",
api_call_count=1,
model=model,
provider=provider,
api_mode=api_mode,
response={"model": model},
usage=usage,
)
return captured["usage_details"], captured["cost_details"]
def _run_both_paths(
self,
mod,
monkeypatch,
usage,
*,
provider="anthropic",
model="priced-model",
api_mode="anthropic_messages",
):
response_result = mod._usage_and_cost(
self._response(
usage["input_tokens"],
usage["output_tokens"],
usage.get("cache_read_tokens", 0),
usage.get("cache_write_tokens", 0),
),
provider=provider,
api_mode=api_mode,
model=model,
base_url="",
)
summary_result = self._capture_summary_path(
mod,
monkeypatch,
usage,
provider=provider,
model=model,
api_mode=api_mode,
)
assert response_result[0] == summary_result[0]
return response_result[1], summary_result[1]
@pytest.mark.parametrize(
("cache_read", "cache_write", "expected_total"),
[
(0, 0, 0.00002),
(2, 3, 0.0000255),
],
ids=("no-cache", "cached"),
)
def test_known_costs_include_canonical_total_on_both_paths(
self,
monkeypatch,
cache_read,
cache_write,
expected_total,
):
sys.modules.pop("plugins.observability.langfuse", None)
mod = importlib.import_module("plugins.observability.langfuse")
import agent.usage_pricing as pricing
entry = pricing.PricingEntry(
input_cost_per_million=Decimal("1"),
output_cost_per_million=Decimal("2"),
cache_read_cost_per_million=Decimal("0.5"),
cache_write_cost_per_million=Decimal("1.5"),
source="custom_contract",
)
monkeypatch.setattr(pricing, "get_pricing_entry", lambda *_, **__: entry)
usage = self._summary(10, 5, cache_read, cache_write)
response_cost, summary_cost = self._run_both_paths(mod, monkeypatch, usage)
expected = {
"total": expected_total,
"input": 0.00001,
"output": 0.00001,
}
if cache_read:
expected["cache_read_input_tokens"] = 0.000001
if cache_write:
expected["cache_creation_input_tokens"] = 0.0000045
assert response_cost == pytest.approx(expected)
assert summary_cost == pytest.approx(expected)
def test_total_uses_request_cost_instead_of_component_sum(self, monkeypatch):
sys.modules.pop("plugins.observability.langfuse", None)
mod = importlib.import_module("plugins.observability.langfuse")
import agent.usage_pricing as pricing
entry = pricing.PricingEntry(
input_cost_per_million=Decimal("1"),
output_cost_per_million=Decimal("2"),
cache_read_cost_per_million=Decimal("0.5"),
cache_write_cost_per_million=Decimal("1.5"),
request_cost=Decimal("0.01"),
source="provider_models_api",
)
monkeypatch.setattr(pricing, "get_pricing_entry", lambda *_, **__: entry)
usage = self._summary(10, 5, cache_read=2, cache_write=3)
response_cost, summary_cost = self._run_both_paths(mod, monkeypatch, usage)
for cost_details in (response_cost, summary_cost):
component_sum = sum(
value for key, value in cost_details.items() if key != "total"
)
assert cost_details["total"] == pytest.approx(0.0100255)
assert component_sum == pytest.approx(0.0000255)
def test_request_only_price_still_exports_total(self, monkeypatch):
sys.modules.pop("plugins.observability.langfuse", None)
mod = importlib.import_module("plugins.observability.langfuse")
import agent.usage_pricing as pricing
entry = pricing.PricingEntry(
request_cost=Decimal("0.01"),
source="provider_models_api",
)
monkeypatch.setattr(pricing, "get_pricing_entry", lambda *_, **__: entry)
usage = self._summary(0, 0)
response_cost, summary_cost = self._run_both_paths(mod, monkeypatch, usage)
assert response_cost == {"total": 0.01}
assert summary_cost == {"total": 0.01}
def test_partial_cache_pricing_exports_no_costs(self, monkeypatch):
sys.modules.pop("plugins.observability.langfuse", None)
mod = importlib.import_module("plugins.observability.langfuse")
import agent.usage_pricing as pricing
entry = pricing.PricingEntry(
input_cost_per_million=Decimal("1"),
output_cost_per_million=Decimal("2"),
cache_read_cost_per_million=None,
source="provider_models_api",
)
monkeypatch.setattr(pricing, "get_pricing_entry", lambda *_, **__: entry)
usage = self._summary(10, 5, cache_read=2)
response_cost, summary_cost = self._run_both_paths(mod, monkeypatch, usage)
assert response_cost == {}
assert summary_cost == {}
def test_unknown_pricing_exports_no_costs(self, monkeypatch):
sys.modules.pop("plugins.observability.langfuse", None)
mod = importlib.import_module("plugins.observability.langfuse")
import agent.usage_pricing as pricing
monkeypatch.setattr(pricing, "get_pricing_entry", lambda *_, **__: None)
usage = self._summary(10, 5)
response_cost, summary_cost = self._run_both_paths(mod, monkeypatch, usage)
assert response_cost == {}
assert summary_cost == {}
def test_included_route_does_not_pin_total(self, monkeypatch):
sys.modules.pop("plugins.observability.langfuse", None)
mod = importlib.import_module("plugins.observability.langfuse")
usage = self._summary(10, 5, cache_read=2)
response_cost, summary_cost = self._run_both_paths(
mod,
monkeypatch,
usage,
provider="openai-codex",
model="gpt-5.3-codex",
api_mode="chat_completions",
)
assert response_cost == summary_cost
assert "total" not in response_cost
# Subscription-included routes must send NO cost keys at all —
# explicit zeros are treated as authoritative by Langfuse and block
# its own model-based estimation (#43129).
assert response_cost == {}