Skip to content

symfonic.core.callbacks.emit_support

emit_support

Timing capture, model-name resolution, and result extraction for emitters.

Split out of :mod:symfonic.core.callbacks.emit (361 lines against the 300-line budget). emit builds and dispatches the callback events; the helpers here answer the three questions every emitter asks first -- how long did the call take, what model actually served it, and what did it return.

emit re-exports all five names, so the existing from ..callbacks.emit import llm_timing, resolve_model_name call sites in nodes/react.py, nodes/context_window.py, learning/refiner.py and their siblings are unaffected.

LLMTiming dataclass

LLMTiming(started_at_utc: str = '', duration_ms: float = 0.0)

Capture window for LLM call timing (v7.15.0).

Populated by the :func:llm_timing async context manager. Passed into :func:emit_llm_end / :func:emit_llm_end_from_result via the timing kwarg so the resulting :class:LLMEndEvent carries duration_ms and started_at_utc for adopter cost-over-time aggregation and tail-latency analysis.

Attributes:

Name Type Description
started_at_utc str

ISO-8601 UTC timestamp of the LLM invocation start. Empty string when the timing context never opened.

duration_ms float

Wall-clock duration in milliseconds. 0.0 when the timing context never closed (e.g. handler dropped the timing reference before exiting the async with).

llm_timing async

llm_timing() -> AsyncIterator[LLMTiming]

Wrap an LLM call to capture wall-clock duration and start timestamp.

Usage::

async with llm_timing() as timing:
    result = await model.ainvoke(...)
await emit_llm_end_from_result(..., timing=timing)

The context manager is exception-safe: duration_ms is populated even when ainvoke raises. Callers that don't care about timing can omit the timing kwarg from the emit helpers -- the defaults preserve the pre-v7.15.0 contract (duration_ms=0.0, started_at_utc="") so adopter handlers that ignore the new fields see byte-identical behaviour.

Yields:

Type Description
AsyncIterator[LLMTiming]

class:LLMTiming -- populated with started_at_utc on

AsyncIterator[LLMTiming]

entry, then duration_ms on exit. Safe to read after the

AsyncIterator[LLMTiming]

context exits.

Source code in src/symfonic/core/callbacks/emit_support.py
@asynccontextmanager
async def llm_timing() -> AsyncIterator[LLMTiming]:
    """Wrap an LLM call to capture wall-clock duration and start timestamp.

    Usage::

        async with llm_timing() as timing:
            result = await model.ainvoke(...)
        await emit_llm_end_from_result(..., timing=timing)

    The context manager is exception-safe: ``duration_ms`` is populated
    even when ``ainvoke`` raises.  Callers that don't care about timing
    can omit the ``timing`` kwarg from the emit helpers -- the defaults
    preserve the pre-v7.15.0 contract (``duration_ms=0.0``,
    ``started_at_utc=""``) so adopter handlers that ignore the new
    fields see byte-identical behaviour.

    Yields:
        :class:`LLMTiming` -- populated with ``started_at_utc`` on
        entry, then ``duration_ms`` on exit.  Safe to read after the
        context exits.
    """
    timing = LLMTiming(
        started_at_utc=datetime.now(UTC).isoformat(),
    )
    t0 = monotonic()
    try:
        yield timing
    finally:
        timing.duration_ms = (monotonic() - t0) * 1000.0

resolve_model_name

resolve_model_name(model: Any, fallback: str = '') -> str

Extract the actual-API-call model identifier from a BaseChatModel.

The framework historically stamped LLMEndEvent.model with the ModelConfig.model_name requested at the call site, but adopters that route via MultiProviderRouter or a custom ModelProvider (the adopter's a custom ModelProvider) can return a chat model bound to a different SKU than was requested. Reporting the requested-name under-attributes cost to the wrong pricing row (the v7.13.6 fix revealed this -- the adopter's stack requested claude-sonnet-4-... via the framework default but the provider returned a Claude Opus 4.6 chat model, which costs ~3x more).

Provider-attribute survey (2026-06-01):

============================================ ============ ========== Concrete class .model .model_name ============================================ ============ ========== langchain_anthropic.ChatAnthropic str None langchain_openai.ChatOpenAI None str langchain_google_genai.ChatGoogleGenerativeAI str None langchain_ollama.ChatOllama str None symfonic.core.testing.MockChatModel absent absent langchain_core.runnables.RunnableBinding absent absent (LangChain's bind_tools wrapper) ============================================ ============ ==========

Strategy: try .model first, then .model_name, then descend once through .bound (the RunnableBinding wrapper exposed by BaseChatModel.bind_tools). Empty strings are treated as "missing" so callers don't end up stamping "" on the cost row. The fallback argument carries the previous behaviour (the ModelConfig.model_name string) so any unknown wrapper degrades to the pre-v7.14.0 contract instead of crashing.

Parameters:

Name Type Description Default
model Any

The BaseChatModel or RunnableBinding actually invoked. None is accepted and routes to fallback.

required
fallback str

String returned when none of the candidate attributes yield a non-empty string -- typically the requested ModelConfig.model_name.

''

Returns:

Type Description
str

The resolved model identifier (e.g. "claude-opus-4-6") when

str

available, otherwise fallback.

Source code in src/symfonic/core/callbacks/emit_support.py
def resolve_model_name(model: Any, fallback: str = "") -> str:
    """Extract the actual-API-call model identifier from a ``BaseChatModel``.

    The framework historically stamped ``LLMEndEvent.model`` with the
    ``ModelConfig.model_name`` requested at the call site, but adopters
    that route via ``MultiProviderRouter`` or a custom ``ModelProvider``
    (the adopter's a custom ``ModelProvider``) can return a chat model bound to
    a different SKU than was requested.  Reporting the requested-name
    under-attributes cost to the wrong pricing row (the v7.13.6 fix
    revealed this -- the adopter's stack requested ``claude-sonnet-4-...``
    via the framework default but the provider returned a Claude Opus
    4.6 chat model, which costs ~3x more).

    Provider-attribute survey (2026-06-01):

    ============================================  ============  ==========
    Concrete class                                 ``.model``    ``.model_name``
    ============================================  ============  ==========
    ``langchain_anthropic.ChatAnthropic``          str           None
    ``langchain_openai.ChatOpenAI``                None          str
    ``langchain_google_genai.ChatGoogleGenerativeAI`` str        None
    ``langchain_ollama.ChatOllama``                str           None
    ``symfonic.core.testing.MockChatModel``        absent        absent
    ``langchain_core.runnables.RunnableBinding``   absent        absent
        (LangChain's ``bind_tools`` wrapper)
    ============================================  ============  ==========

    Strategy: try ``.model`` first, then ``.model_name``, then descend
    once through ``.bound`` (the RunnableBinding wrapper exposed by
    ``BaseChatModel.bind_tools``).  Empty strings are treated as
    "missing" so callers don't end up stamping ``""`` on the cost row.
    The ``fallback`` argument carries the previous behaviour (the
    ``ModelConfig.model_name`` string) so any unknown wrapper degrades
    to the pre-v7.14.0 contract instead of crashing.

    Args:
        model: The ``BaseChatModel`` or ``RunnableBinding`` actually
            invoked.  ``None`` is accepted and routes to ``fallback``.
        fallback: String returned when none of the candidate attributes
            yield a non-empty string -- typically the requested
            ``ModelConfig.model_name``.

    Returns:
        The resolved model identifier (e.g. ``"claude-opus-4-6"``) when
        available, otherwise ``fallback``.
    """
    if model is None:
        return fallback
    for attr in ("model", "model_name"):
        v = getattr(model, attr, None)
        if isinstance(v, str) and v:
            return v
    # RunnableBinding (bind_tools wrapper) exposes the underlying chat
    # model under ``.bound``.  One level of descent is enough -- bind_tools
    # never returns a doubly-wrapped binding in any langchain version we
    # support.
    inner = getattr(model, "bound", None)
    if inner is not None and inner is not model:
        for attr in ("model", "model_name"):
            v = getattr(inner, attr, None)
            if isinstance(v, str) and v:
                return v
    return fallback