Files
nvi/api/langfuse_client.py

99 lines
2.6 KiB
Python

"""Langfuse SDK wrapper — single source of truth for the client and spans.
Used by every layer (tools, analyses, plan, runtime, evals), which is why it
lives at the api/ root rather than under any one layer.
Gracefully returns no-op spans when Langfuse keys aren't configured, so local
dev without Langfuse still works.
"""
from __future__ import annotations
import logging
from contextlib import contextmanager
from typing import Any, Iterator
from api.config import get_settings
logger = logging.getLogger("nvi.langfuse")
_client: Any = None
def get_client() -> Any | None:
global _client
if _client is not None:
return _client
s = get_settings()
if not s.langfuse_public_key or not s.langfuse_secret_key:
return None
try:
from langfuse import Langfuse
_client = Langfuse(
public_key=s.langfuse_public_key,
secret_key=s.langfuse_secret_key,
host=s.langfuse_host,
)
return _client
except Exception as e:
logger.warning("langfuse init failed: %s", e)
return None
class _NullSpan:
"""No-op stand-in when Langfuse isn't configured."""
def update(self, **_: Any) -> None: ...
def end(self, **_: Any) -> None: ...
def __enter__(self) -> "_NullSpan": return self
def __exit__(self, *_: Any) -> None: ...
@contextmanager
def span(
name: str,
*,
as_type: str = "span",
input: Any = None,
metadata: dict[str, Any] | None = None,
model: str | None = None,
) -> Iterator[Any]:
"""Open a Langfuse observation; yields the span object or a no-op.
Pass `model="<id>"` when `as_type="generation"` so Langfuse can label
the trace and compute cost when it knows the model's pricing. Token
usage is reported by the body via `span.update(usage_details=...)`.
Exceptions raised by the *body* of the `with` block propagate up
untouched. Only Langfuse's own setup failures fall through to a no-op
span — we never want to mask a real error.
"""
lf = get_client()
if lf is None:
yield _NullSpan()
return
kwargs: dict[str, Any] = {
"name": name,
"as_type": as_type,
"input": input,
"metadata": metadata or {},
}
if model is not None:
kwargs["model"] = model
try:
cm = lf.start_as_current_observation(**kwargs)
except Exception as e:
logger.warning("langfuse setup for %s failed: %s", name, e)
yield _NullSpan()
return
with cm as s:
yield s
def flush() -> None:
lf = get_client()
if lf is not None:
try:
lf.flush()
except Exception:
pass