diff --git a/src/llm/api.py b/src/llm/api.py index 4639cad4..2a8f0529 100644 --- a/src/llm/api.py +++ b/src/llm/api.py @@ -31,6 +31,7 @@ from .runtime import ( effective_temperature, plan_attempt, resolve_runtime_model_config, + update_current_langfuse_observation, ) from .tool_loop import execute_tool_loop from .types import ( @@ -193,13 +194,19 @@ async def honcho_llm_call( current_attempt.set(1) def _get_attempt_plan() -> AttemptPlan: - return plan_attempt( + plan = plan_attempt( runtime_model_config=runtime_model_config, attempt=current_attempt.get(), retry_attempts=retry_attempts, call_thinking_budget_tokens=thinking_budget_tokens, call_reasoning_effort=reasoning_effort, ) + update_current_langfuse_observation( + plan.provider, + plan.model, + name=track_name, + ) + return plan async def _call_with_provider_selection() -> ( HonchoLLMCallResponse[Any] | AsyncIterator[HonchoLLMCallStreamChunk] diff --git a/src/llm/runtime.py b/src/llm/runtime.py index 27bc56d9..2c29f397 100644 --- a/src/llm/runtime.py +++ b/src/llm/runtime.py @@ -21,6 +21,7 @@ from src.config import ( ModelConfig, ModelTransport, resolve_model_config, + settings, ) from .registry import backend_for_provider, client_for_model_config @@ -32,6 +33,33 @@ logger = logging.getLogger(__name__) current_attempt: ContextVar[int] = ContextVar("current_attempt", default=0) +def update_current_langfuse_observation( + provider: ModelTransport, + model: str, + *, + name: str | None = None, +) -> None: + """Best-effort annotation of the current Langfuse span with LLM routing.""" + if not settings.LANGFUSE_PUBLIC_KEY: + return + + try: + from langfuse import get_client + + update_kwargs: dict[str, Any] = { + "metadata": { + "namespace": settings.NAMESPACE, + "provider": provider, + "model": model, + } + } + if name is not None: + update_kwargs["name"] = name + get_client().update_current_span(**update_kwargs) + except Exception as exc: # pragma: no cover - best-effort telemetry + logger.debug("Failed to update Langfuse span metadata: %s", exc) + + @dataclass(frozen=True) class AttemptPlan: """Per-attempt plan produced by `plan_attempt`. @@ -204,4 +232,5 @@ __all__ = [ "resolve_backend_for_plan", "resolve_runtime_model_config", "select_model_config_for_attempt", + "update_current_langfuse_observation", ] diff --git a/tests/utils/test_clients.py b/tests/utils/test_clients.py index 213a370c..f5506790 100644 --- a/tests/utils/test_clients.py +++ b/tests/utils/test_clients.py @@ -23,7 +23,12 @@ from openai.types.chat.chat_completion_message import ChatCompletionMessage from openai.types.completion_usage import CompletionUsage from pydantic import BaseModel, Field -from src.config import ConfiguredModelSettings, ModelConfig, ResolvedFallbackConfig +from src.config import ( + ConfiguredModelSettings, + ModelConfig, + ResolvedFallbackConfig, + settings, +) from src.exceptions import LLMError, ValidationException from src.llm import ( CLIENTS, @@ -905,6 +910,44 @@ class TestMainLLMCallFunction: assert response.content == "No retry response" + async def test_track_name_updates_langfuse_span_name(self): + """track_name should rename the top-level Langfuse span.""" + + mock_llm_client = AsyncMock(spec=AsyncAnthropic) + mock_response = Mock() + mock_response.content = [TextBlock(text="Named response", type="text")] + mock_response.usage = Usage(input_tokens=5, output_tokens=5) + mock_response.stop_reason = "stop" + mock_llm_client.messages.create = AsyncMock(return_value=mock_response) + + mock_langfuse_client = Mock() + + with ( + patch.dict(CLIENTS, {"anthropic": mock_llm_client}), + patch.object(settings, "LANGFUSE_PUBLIC_KEY", "test-public-key"), + patch("langfuse.get_client", return_value=mock_langfuse_client), + ): + response = await honcho_llm_call( + model_config=ConfiguredModelSettings( + model="claude-4-sonnet", + transport="anthropic", + ), + prompt="Hello", + max_tokens=100, + enable_retry=False, + track_name="Dialectic Agent", + ) + + assert response.content == "Named response" + mock_langfuse_client.update_current_span.assert_called_once_with( + name="Dialectic Agent", + metadata={ + "namespace": settings.NAMESPACE, + "provider": "anthropic", + "model": "claude-4-sonnet", + }, + ) + class TestEdgeCases: """Tests for edge cases and boundary conditions"""