Download tests/plugins/test_langfuse_plugin.py from SaylorTwift/hermes-agent: direct link, hf CLI and curl.
- Browser
- Download file 97.8 kB
-
https://huggingface.co/SaylorTwift/hermes-agent/resolve/main/tests/plugins/test_langfuse_plugin.py
- Command line
-
hf download hf://SaylorTwift/hermes-agent/tests/plugins/test_langfuse_plugin.py
-
curl -L -o test_langfuse_plugin.py https://huggingface.co/SaylorTwift/hermes-agent/resolve/main/tests/plugins/test_langfuse_plugin.py
97.8 kB
| """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") | |
| 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 | |
| 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 | |
| def test_finalize_flushes_every_profile_without_an_ambient_scope(self, monkeypatch, tmp_path): | |
| """Multiplex gateway: every turn ran inside a profile scope (home override + secret scope), | |
| so only the per-home slots hold clients and the launch slot stays empty. atexit has no | |
| scope, and a credential read there raises UnscopedSecretError. The finalizer must not read | |
| credentials at all — it ends the open roots and flushes each settled client, so neither | |
| profile loses its pending traces.""" | |
| from agent import secret_scope | |
| from hermes_constants import reset_hermes_home_override, set_hermes_home_override | |
| mod = self._fresh_plugin() | |
| monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", True) | |
| monkeypatch.setattr(mod, "_end_observation", lambda obs, **k: None) | |
| mod._LANGFUSE_CLIENT = None | |
| mod._TRACE_STATE.clear() | |
| mod._LANGFUSE_CLIENT_BY_HOME.clear() | |
| flushed: list = [] | |
| fake_client = self._fake_client | |
| def _sdk(**kw): | |
| client = fake_client([]) | |
| client.flush = lambda pk=kw["public_key"]: flushed.append(pk) | |
| return client | |
| monkeypatch.setattr(mod, "Langfuse", _sdk) | |
| for profile in ("alpha", "beta"): | |
| home = tmp_path / profile | |
| home.mkdir() | |
| home_token = set_hermes_home_override(home) | |
| scope_token = secret_scope.set_secret_scope({ | |
| "HERMES_LANGFUSE_PUBLIC_KEY": f"pk-lf-{profile}-0123456789", | |
| "HERMES_LANGFUSE_SECRET_KEY": f"sk-lf-{profile}-0123456789", | |
| }) | |
| try: | |
| self._run_turn(mod, session=f"{profile}-turn", turn_n=0, finalize=False) | |
| finally: | |
| secret_scope.reset_secret_scope(scope_token) | |
| reset_hermes_home_override(home_token) | |
| assert len(mod._TRACE_STATE) == 2 and len(mod._LANGFUSE_CLIENT_BY_HOME) == 2 | |
| assert mod._LANGFUSE_CLIENT is None and secret_scope.current_secret_scope() is None | |
| mod._finalize_all_traces() # no scope: must not raise, must not build a client | |
| assert sorted(flushed) == ["pk-lf-alpha-0123456789", "pk-lf-beta-0123456789"] | |
| assert mod._TRACE_STATE == {} and mod._LANGFUSE_CLIENT is None | |
| 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.""" | |
| 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) | |
| 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, | |
| } | |
| 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] | |
| 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 == {} | |