617 lines
23 KiB
Python
617 lines
23 KiB
Python
"""Single-call executor: the inner LLM-call path without tool-loop orchestration.
|
|
|
|
`honcho_llm_call_inner` handles one backend call (complete or stream), building
|
|
the effective ModelConfig and delegating to request_builder. Result / stream
|
|
chunk types are bridged to the public Honcho* shapes here.
|
|
|
|
Used by:
|
|
- src/llm/api.py (the public entrypoint, for both tool-less and tool-enabled paths)
|
|
- src/llm/tool_loop.py (each iteration of the tool loop calls this)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import time
|
|
from collections.abc import AsyncIterator
|
|
from typing import Any, Literal, TypeVar, overload
|
|
|
|
from pydantic import BaseModel
|
|
|
|
from src.config import ModelConfig, ModelTransport, settings
|
|
from src.telemetry.logging import conditional_observe
|
|
|
|
from .backend import CompletionResult as BackendCompletionResult
|
|
from .backend import StreamChunk as BackendStreamChunk
|
|
from .backend import ToolCallResult
|
|
from .capture import build_captured_call, dispatch_captured_call, has_exporters
|
|
from .registry import backend_for_provider, default_client
|
|
from .request_builder import execute_completion, execute_stream
|
|
from .runtime import (
|
|
AttemptPlan,
|
|
annotate_current_generation_io,
|
|
annotate_current_langfuse_trace,
|
|
effective_config_for_call,
|
|
)
|
|
from .types import (
|
|
HonchoLLMCallResponse,
|
|
HonchoLLMCallStreamChunk,
|
|
LLMTelemetryContext,
|
|
ProviderClient,
|
|
ReasoningEffortType,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
M = TypeVar("M", bound=BaseModel)
|
|
|
|
|
|
# ModelConfig fields that must NEVER reach a trace: secrets and nested holders
|
|
# of secrets. Everything else on the config is a safe tuning knob and is dumped
|
|
# automatically — so new knobs get traced without touching this code. Keep this
|
|
# a deny-list (small, stable) rather than an allow-list (drifts with the model).
|
|
_UNSAFE_CONFIG_FIELDS = frozenset(
|
|
{
|
|
"api_key", # provider secret
|
|
"base_url", # may embed credentials / private host
|
|
"fallback", # ResolvedFallbackConfig carries its own api_key/base_url
|
|
"provider_params", # opaque dict; can carry auth headers/keys
|
|
}
|
|
)
|
|
|
|
|
|
def _langfuse_model_parameters(
|
|
*,
|
|
max_tokens: int,
|
|
config: ModelConfig,
|
|
json_mode: bool,
|
|
verbosity: str | None,
|
|
stream: bool,
|
|
tools: list[dict[str, Any]] | None,
|
|
tool_choice: str | dict[str, Any] | None,
|
|
response_model: type[BaseModel] | None,
|
|
) -> dict[str, Any]:
|
|
"""Serializable tuning knobs for the Langfuse generation.
|
|
|
|
Surfaces everything @observe auto-capture used to show (temperature, tools,
|
|
...) MINUS the live client and the secret-bearing config fields. We dump the
|
|
resolved `effective_config` and deny-list only `_UNSAFE_CONFIG_FIELDS`, so a
|
|
new ModelConfig knob is traced automatically — no allow-list to keep in sync.
|
|
`mode="json"` coerces enums/sub-models to JSON-safe values. See HONCHO-4HA.
|
|
"""
|
|
params: dict[str, Any] = config.model_dump(
|
|
exclude=set(_UNSAFE_CONFIG_FIELDS), exclude_none=True, mode="json"
|
|
)
|
|
# Per-call extras that live outside ModelConfig.
|
|
params["max_tokens"] = max_tokens
|
|
params["stream"] = stream
|
|
params["json_mode"] = json_mode
|
|
if verbosity is not None:
|
|
params["verbosity"] = verbosity
|
|
if response_model is not None:
|
|
params["response_format"] = response_model.__name__
|
|
if tools:
|
|
params["tools"] = [
|
|
t.get("name") or t.get("function", {}).get("name") or "unknown"
|
|
for t in tools
|
|
]
|
|
if tool_choice is not None:
|
|
params["tool_choice"] = (
|
|
tool_choice if isinstance(tool_choice, str) else str(tool_choice)
|
|
)
|
|
return params
|
|
|
|
|
|
def _langfuse_usage_details(response: HonchoLLMCallResponse[Any]) -> dict[str, int]:
|
|
"""Token usage duplicated onto the Langfuse generation.
|
|
|
|
These counts are also emitted via CloudEvents (LLMCallCompletedEvent), but
|
|
we mirror them here so Langfuse renders per-call tokens + cost natively,
|
|
including Anthropic-style prompt-cache reads/writes. Zero-valued cache keys
|
|
are dropped so non-cached calls stay tidy. Stream calls don't surface token
|
|
totals at this layer, so usage is set only on the non-stream path.
|
|
"""
|
|
usage: dict[str, int] = {
|
|
"input": response.input_tokens,
|
|
"output": response.output_tokens,
|
|
}
|
|
if response.cache_read_input_tokens:
|
|
usage["cache_read_input_tokens"] = response.cache_read_input_tokens
|
|
if response.cache_creation_input_tokens:
|
|
usage["cache_creation_input_tokens"] = response.cache_creation_input_tokens
|
|
return usage
|
|
|
|
|
|
def _outcome_from_error(
|
|
err: BaseException | None,
|
|
) -> Literal["success", "error", "cancelled"]:
|
|
"""Map a finally-block error into the telemetry outcome literal.
|
|
|
|
CancelledError is a normal control-flow event (client disconnect, server
|
|
shutdown) — surface it distinctly so it doesn't pollute error-rate alerts.
|
|
"""
|
|
if err is None:
|
|
return "success"
|
|
if isinstance(err, asyncio.CancelledError):
|
|
return "cancelled"
|
|
return "error"
|
|
|
|
|
|
def _tool_call_result_to_dict(tool_call: ToolCallResult) -> dict[str, Any]:
|
|
result: dict[str, Any] = {
|
|
"id": tool_call.id,
|
|
"name": tool_call.name,
|
|
"input": tool_call.input,
|
|
}
|
|
if tool_call.thought_signature is not None:
|
|
result["thought_signature"] = tool_call.thought_signature
|
|
return result
|
|
|
|
|
|
def _emit_llm_call_completed(
|
|
*,
|
|
plan: AttemptPlan | None,
|
|
telemetry: LLMTelemetryContext | None,
|
|
provider: ModelTransport,
|
|
model: str,
|
|
max_tokens: int,
|
|
duration_ms: float,
|
|
has_tools: bool,
|
|
was_stream: bool,
|
|
outcome: Literal["success", "error", "cancelled"],
|
|
result: BackendCompletionResult | None,
|
|
error: BaseException | None,
|
|
) -> None:
|
|
"""Build and emit an LLMCallCompletedEvent. Best-effort; swallows errors so
|
|
telemetry failures never bleed into the LLM call path."""
|
|
try:
|
|
from src.telemetry.events import CallPurpose, LLMCallCompletedEvent, emit
|
|
|
|
# call_purpose is a string slug on LLMTelemetryContext; validate against
|
|
# the enum here (silent drop on unknown values keeps telemetry resilient).
|
|
call_purpose: CallPurpose | None = None
|
|
if telemetry is not None and telemetry.call_purpose:
|
|
try:
|
|
call_purpose = CallPurpose(telemetry.call_purpose)
|
|
except ValueError:
|
|
logger.debug(
|
|
"Unknown LLMTelemetryContext.call_purpose=%r; emitting without",
|
|
telemetry.call_purpose,
|
|
)
|
|
|
|
attempt = plan.attempt if plan is not None else 1
|
|
retry_attempts = plan.retry_attempts if plan is not None else 1
|
|
was_fallback = plan.is_fallback if plan is not None else False
|
|
|
|
emit(
|
|
LLMCallCompletedEvent(
|
|
workspace_name=(telemetry.workspace_name if telemetry else None),
|
|
call_purpose=call_purpose,
|
|
parent_category=(telemetry.parent_category if telemetry else None),
|
|
transport=provider,
|
|
provider_label=infer_provider_label(provider, model, plan),
|
|
model=model,
|
|
effective_max_output_tokens=max_tokens,
|
|
provider_input_tokens=(result.input_tokens if result else 0),
|
|
provider_output_tokens=(result.output_tokens if result else 0),
|
|
cache_read_tokens=(result.cache_read_input_tokens if result else 0),
|
|
cache_creation_tokens=(
|
|
result.cache_creation_input_tokens if result else 0
|
|
),
|
|
finish_reason=(result.finish_reason if result else None),
|
|
outcome=outcome,
|
|
is_final_attempt=(attempt >= retry_attempts),
|
|
error_class=(type(error).__name__ if error else None),
|
|
attempt=attempt,
|
|
retry_attempts=retry_attempts,
|
|
was_fallback=was_fallback,
|
|
duration_ms=duration_ms,
|
|
has_tools=has_tools,
|
|
tool_call_count=(len(result.tool_calls) if result else 0),
|
|
was_stream=was_stream,
|
|
run_id=(telemetry.run_id if telemetry else None),
|
|
iteration=(telemetry.iteration if telemetry else None),
|
|
)
|
|
)
|
|
except Exception: # pragma: no cover - telemetry must not raise
|
|
logger.debug("Failed to emit LLMCallCompletedEvent", exc_info=True)
|
|
|
|
|
|
def infer_provider_label(
|
|
_transport: ModelTransport, model: str, plan: AttemptPlan | None
|
|
) -> str | None:
|
|
"""Best-effort vendor inference for relay setups.
|
|
|
|
When the model name carries a vendor prefix (OpenRouter convention:
|
|
"anthropic/claude-..." routed through the openai transport), surface that
|
|
as the provider label so analytics can distinguish "openai-the-vendor"
|
|
from "openai-the-transport-pointing-at-openrouter".
|
|
|
|
`_transport` is currently unused but kept on the signature so callers stay
|
|
explicit about which transport produced the call — future inference rules
|
|
(e.g. anthropic-direct vs anthropic-via-relay) may need it.
|
|
"""
|
|
if "/" in model:
|
|
return model.split("/", 1)[0]
|
|
# Defensive getattr — selected_config may be a stub in tests or a config
|
|
# without an explicit base_url. Either way the inference is best-effort.
|
|
base_url = (
|
|
getattr(plan.selected_config, "base_url", None) if plan is not None else None
|
|
)
|
|
if base_url and "openrouter" in base_url.lower():
|
|
return "openrouter"
|
|
return None
|
|
|
|
|
|
def _maybe_dispatch_capture(
|
|
*,
|
|
plan: AttemptPlan | None,
|
|
telemetry: LLMTelemetryContext | None,
|
|
provider: ModelTransport,
|
|
model: str,
|
|
messages: list[dict[str, Any]],
|
|
tools: list[dict[str, Any]] | None,
|
|
tool_choice: Any,
|
|
result: BackendCompletionResult | None,
|
|
error: BaseException | None,
|
|
) -> None:
|
|
"""Build a CapturedLLMCall and fan it out to registered exporters.
|
|
|
|
No-op when payload capture is off
|
|
`has_exporters()` is checked BEFORE building.
|
|
Best-effort: never raises into the call path.
|
|
"""
|
|
if not has_exporters():
|
|
return
|
|
try:
|
|
outcome = _outcome_from_error(error)
|
|
finish_reason = result.finish_reason if result is not None else outcome
|
|
dispatch_captured_call(
|
|
build_captured_call(
|
|
telemetry=telemetry,
|
|
transport=str(provider),
|
|
provider_label=infer_provider_label(provider, model, plan),
|
|
model=model,
|
|
messages=messages,
|
|
tools=tools,
|
|
tool_choice=tool_choice,
|
|
result=result,
|
|
attempt=plan.attempt if plan is not None else 1,
|
|
was_fallback=plan.is_fallback if plan is not None else False,
|
|
was_stream=False,
|
|
finish_reason=finish_reason,
|
|
)
|
|
)
|
|
except Exception: # pragma: no cover - best-effort telemetry
|
|
logger.debug("Failed to dispatch CapturedLLMCall", exc_info=True)
|
|
|
|
|
|
def completion_result_to_response(
|
|
result: BackendCompletionResult,
|
|
) -> HonchoLLMCallResponse[Any]:
|
|
return HonchoLLMCallResponse(
|
|
content=result.content,
|
|
input_tokens=result.input_tokens,
|
|
output_tokens=result.output_tokens or 0,
|
|
cache_creation_input_tokens=result.cache_creation_input_tokens,
|
|
cache_read_input_tokens=result.cache_read_input_tokens,
|
|
finish_reasons=[result.finish_reason] if result.finish_reason else [],
|
|
tool_calls_made=[_tool_call_result_to_dict(tc) for tc in result.tool_calls],
|
|
thinking_content=result.thinking_content,
|
|
thinking_blocks=result.thinking_blocks,
|
|
reasoning_details=result.reasoning_details,
|
|
)
|
|
|
|
|
|
def stream_chunk_to_response_chunk(
|
|
chunk: BackendStreamChunk,
|
|
) -> HonchoLLMCallStreamChunk:
|
|
return HonchoLLMCallStreamChunk(
|
|
content=chunk.content,
|
|
is_done=chunk.is_done,
|
|
finish_reasons=[chunk.finish_reason] if chunk.finish_reason else [],
|
|
output_tokens=chunk.output_tokens,
|
|
)
|
|
|
|
|
|
@overload
|
|
async def honcho_llm_call_inner(
|
|
provider: ModelTransport,
|
|
model: str,
|
|
prompt: str,
|
|
max_tokens: int,
|
|
response_model: type[M],
|
|
json_mode: bool = False,
|
|
temperature: float | None = None,
|
|
stop_seqs: list[str] | None = None,
|
|
reasoning_effort: ReasoningEffortType = None,
|
|
verbosity: Literal["low", "medium", "high"] | None = None,
|
|
thinking_budget_tokens: int | None = None,
|
|
stream: Literal[False] = False,
|
|
client_override: ProviderClient | None = None,
|
|
tools: list[dict[str, Any]] | None = None,
|
|
tool_choice: str | dict[str, Any] | None = None,
|
|
messages: list[dict[str, Any]] | None = None,
|
|
selected_config: ModelConfig | None = None,
|
|
plan: AttemptPlan | None = None,
|
|
telemetry: LLMTelemetryContext | None = None,
|
|
) -> HonchoLLMCallResponse[M]: ...
|
|
|
|
|
|
@overload
|
|
async def honcho_llm_call_inner(
|
|
provider: ModelTransport,
|
|
model: str,
|
|
prompt: str,
|
|
max_tokens: int,
|
|
response_model: None = None,
|
|
json_mode: bool = False,
|
|
temperature: float | None = None,
|
|
stop_seqs: list[str] | None = None,
|
|
reasoning_effort: ReasoningEffortType = None,
|
|
verbosity: Literal["low", "medium", "high"] | None = None,
|
|
thinking_budget_tokens: int | None = None,
|
|
stream: Literal[False] = False,
|
|
client_override: ProviderClient | None = None,
|
|
tools: list[dict[str, Any]] | None = None,
|
|
tool_choice: str | dict[str, Any] | None = None,
|
|
messages: list[dict[str, Any]] | None = None,
|
|
selected_config: ModelConfig | None = None,
|
|
plan: AttemptPlan | None = None,
|
|
telemetry: LLMTelemetryContext | None = None,
|
|
) -> HonchoLLMCallResponse[str]: ...
|
|
|
|
|
|
@overload
|
|
async def honcho_llm_call_inner(
|
|
provider: ModelTransport,
|
|
model: str,
|
|
prompt: str,
|
|
max_tokens: int,
|
|
response_model: type[BaseModel] | None = None,
|
|
json_mode: bool = False,
|
|
temperature: float | None = None,
|
|
stop_seqs: list[str] | None = None,
|
|
reasoning_effort: ReasoningEffortType = None,
|
|
verbosity: Literal["low", "medium", "high"] | None = None,
|
|
thinking_budget_tokens: int | None = None,
|
|
stream: Literal[True] = ...,
|
|
client_override: ProviderClient | None = None,
|
|
tools: list[dict[str, Any]] | None = None,
|
|
tool_choice: str | dict[str, Any] | None = None,
|
|
messages: list[dict[str, Any]] | None = None,
|
|
selected_config: ModelConfig | None = None,
|
|
plan: AttemptPlan | None = None,
|
|
telemetry: LLMTelemetryContext | None = None,
|
|
) -> AsyncIterator[HonchoLLMCallStreamChunk]: ...
|
|
|
|
|
|
@conditional_observe(
|
|
name="LLM Call",
|
|
as_type="generation",
|
|
# Disable @observe auto-capture: it would serialize `client_override` (a
|
|
# live AsyncOpenAI/genai client) and `selected_config` (carries api_key)
|
|
# into the span. Auto-capture deep-copies the client into a half-built
|
|
# object whose teardown raises `_state`/`_http_options` AttributeErrors
|
|
# (HONCHO-4HA) and leaks the key. We set curated input/output explicitly
|
|
# below via `annotate_current_generation_io`, preserving full fidelity.
|
|
capture_input=False,
|
|
capture_output=False,
|
|
)
|
|
async def honcho_llm_call_inner(
|
|
provider: ModelTransport,
|
|
model: str,
|
|
prompt: str,
|
|
max_tokens: int,
|
|
response_model: type[BaseModel] | None = None,
|
|
json_mode: bool = False,
|
|
temperature: float | None = None,
|
|
stop_seqs: list[str] | None = None,
|
|
reasoning_effort: ReasoningEffortType = None,
|
|
verbosity: Literal["low", "medium", "high"] | None = None,
|
|
thinking_budget_tokens: int | None = None,
|
|
stream: bool = False,
|
|
client_override: ProviderClient | None = None,
|
|
tools: list[dict[str, Any]] | None = None,
|
|
tool_choice: str | dict[str, Any] | None = None,
|
|
messages: list[dict[str, Any]] | None = None,
|
|
selected_config: ModelConfig | None = None,
|
|
plan: AttemptPlan | None = None,
|
|
telemetry: LLMTelemetryContext | None = None,
|
|
) -> HonchoLLMCallResponse[Any] | AsyncIterator[HonchoLLMCallStreamChunk]:
|
|
"""One backend call. No retry, no fallback, no tool loop.
|
|
|
|
This is the Langfuse trace boundary (``@conditional_observe``): every
|
|
provider call is its own trace. Multi-turn agents thread a shared
|
|
``run_id`` through ``telemetry`` so their per-iteration traces roll up into
|
|
one Langfuse session (see ``annotate_current_langfuse_trace``).
|
|
|
|
The outer src/llm/api.py `honcho_llm_call` handles retry + fallback +
|
|
tool orchestration on top of this.
|
|
|
|
Emits one LLMCallCompletedEvent per call. On the stream path, setup
|
|
runs inside the awaited coroutine (so it sits inside any outer retry
|
|
wrapper) and emits its own event on failure; the wrapping generator
|
|
emits a second event from its finally block after drain completes or
|
|
raises. `was_stream` is True for streamed calls. Token counts are
|
|
zero on the stream path because provider token totals aren't surfaced
|
|
post-stream at this layer; aggregate envelopes (DialecticCompletedEvent
|
|
etc.) carry the accurate totals.
|
|
"""
|
|
client = client_override or default_client(provider)
|
|
if client is None:
|
|
raise ValueError(f"Missing client for {provider}")
|
|
|
|
# Stamp this trace (user_id/session_id/metadata) now that the @observe
|
|
# span is open and the resolved provider/model are known. Set early so the
|
|
# annotation lands even on the stream path, where the span closes once the
|
|
# generator is returned (before chunks drain).
|
|
annotate_current_langfuse_trace(provider, model, telemetry=telemetry)
|
|
|
|
if messages is None:
|
|
messages = [{"role": "user", "content": prompt}]
|
|
|
|
backend = backend_for_provider(provider, client)
|
|
|
|
effective_config = effective_config_for_call(
|
|
selected_config=selected_config,
|
|
provider=provider,
|
|
model=model,
|
|
temperature=temperature,
|
|
stop_seqs=stop_seqs,
|
|
thinking_budget_tokens=thinking_budget_tokens,
|
|
reasoning_effort=reasoning_effort,
|
|
)
|
|
|
|
# Explicit generation input + tuning knobs (replaces @observe auto-capture,
|
|
# which would serialize the live client / api key). Set before the stream
|
|
# branch so it lands on the generation span for both paths. Guard on inline
|
|
# mode (matching annotate_current_generation_io's own gate) so we don't
|
|
# build the (model_dump-backed) payload when the helper would no-op — in
|
|
# exporter mode there's no active generation span to stamp.
|
|
if settings.langfuse_inline_enabled:
|
|
annotate_current_generation_io(
|
|
input=messages,
|
|
model_parameters=_langfuse_model_parameters(
|
|
max_tokens=max_tokens,
|
|
config=effective_config,
|
|
json_mode=json_mode,
|
|
verbosity=verbosity,
|
|
stream=stream,
|
|
tools=tools,
|
|
tool_choice=tool_choice,
|
|
response_model=response_model,
|
|
),
|
|
)
|
|
# json_mode + verbosity are per-call transport toggles, not ModelConfig
|
|
# knobs — they pass through extra_params. execute_completion merges
|
|
# build_config_extra_params(effective_config) on top for top_p/seed/etc.
|
|
call_extras: dict[str, Any] = {"json_mode": json_mode, "verbosity": verbosity}
|
|
|
|
if stream:
|
|
# Stream path: setup must run inside the awaited coroutine so it
|
|
# sits inside the outer retry wrapper (tool_loop.stream_final_response
|
|
# wraps `await honcho_llm_call_inner(stream=True)` with tenacity).
|
|
# If we deferred `execute_stream` into the generator body, a transient
|
|
# setup failure (rate-limit, auth, network) would surface at first
|
|
# iteration — outside retry — and crash the request.
|
|
#
|
|
# Drain failures stay unretried by design (chunks may have already
|
|
# been sent to the client) and report via the wrapper's finally.
|
|
# Token counts are 0 on this path; aggregate envelopes carry totals.
|
|
stream_start = time.perf_counter()
|
|
try:
|
|
stream_iter = await execute_stream(
|
|
backend,
|
|
effective_config,
|
|
messages=messages,
|
|
max_tokens=max_tokens,
|
|
tools=tools,
|
|
tool_choice=tool_choice,
|
|
response_format=response_model,
|
|
cache_policy=effective_config.cache_policy,
|
|
extra_params=call_extras,
|
|
)
|
|
except BaseException as exc:
|
|
_emit_llm_call_completed(
|
|
plan=plan,
|
|
telemetry=telemetry,
|
|
provider=provider,
|
|
model=model,
|
|
max_tokens=max_tokens,
|
|
duration_ms=(time.perf_counter() - stream_start) * 1000,
|
|
has_tools=bool(tools),
|
|
was_stream=True,
|
|
outcome=_outcome_from_error(exc),
|
|
result=None,
|
|
error=exc,
|
|
)
|
|
raise
|
|
|
|
async def _wrap_stream() -> AsyncIterator[HonchoLLMCallStreamChunk]:
|
|
stream_error: BaseException | None = None
|
|
try:
|
|
async for chunk in stream_iter:
|
|
yield stream_chunk_to_response_chunk(chunk)
|
|
except BaseException as exc:
|
|
stream_error = exc
|
|
raise
|
|
finally:
|
|
_emit_llm_call_completed(
|
|
plan=plan,
|
|
telemetry=telemetry,
|
|
provider=provider,
|
|
model=model,
|
|
max_tokens=max_tokens,
|
|
duration_ms=(time.perf_counter() - stream_start) * 1000,
|
|
has_tools=bool(tools),
|
|
was_stream=True,
|
|
outcome=_outcome_from_error(stream_error),
|
|
result=None,
|
|
error=stream_error,
|
|
)
|
|
|
|
return _wrap_stream()
|
|
|
|
start = time.perf_counter()
|
|
backend_result: BackendCompletionResult | None = None
|
|
error: BaseException | None = None
|
|
try:
|
|
backend_result = await execute_completion(
|
|
backend,
|
|
effective_config,
|
|
messages=messages,
|
|
max_tokens=max_tokens,
|
|
tools=tools,
|
|
tool_choice=tool_choice,
|
|
response_format=response_model,
|
|
cache_policy=effective_config.cache_policy,
|
|
extra_params=call_extras,
|
|
)
|
|
response = completion_result_to_response(backend_result)
|
|
# Explicit generation output + token usage (replaces @observe
|
|
# auto-capture). The stream path closes this span before drain, so its
|
|
# output is stamped on the run-level span instead
|
|
if settings.langfuse_inline_enabled:
|
|
annotate_current_generation_io(
|
|
output=response,
|
|
usage_details=_langfuse_usage_details(response),
|
|
)
|
|
return response
|
|
except BaseException as exc:
|
|
error = exc
|
|
raise
|
|
finally:
|
|
_emit_llm_call_completed(
|
|
plan=plan,
|
|
telemetry=telemetry,
|
|
provider=provider,
|
|
model=model,
|
|
max_tokens=max_tokens,
|
|
duration_ms=(time.perf_counter() - start) * 1000,
|
|
has_tools=bool(tools),
|
|
was_stream=False,
|
|
outcome=_outcome_from_error(error),
|
|
result=backend_result,
|
|
error=error,
|
|
)
|
|
_maybe_dispatch_capture(
|
|
plan=plan,
|
|
telemetry=telemetry,
|
|
provider=provider,
|
|
model=model,
|
|
messages=messages,
|
|
tools=tools,
|
|
tool_choice=tool_choice,
|
|
result=backend_result,
|
|
error=error,
|
|
)
|
|
|
|
|
|
__all__ = [
|
|
"completion_result_to_response",
|
|
"honcho_llm_call_inner",
|
|
"stream_chunk_to_response_chunk",
|
|
]
|