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
¶
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. |
llm_timing
async
¶
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: |
AsyncIterator[LLMTiming]
|
entry, then |
AsyncIterator[LLMTiming]
|
context exits. |
Source code in src/symfonic/core/callbacks/emit_support.py
resolve_model_name ¶
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 |
required |
fallback
|
str
|
String returned when none of the candidate attributes
yield a non-empty string -- typically the requested
|
''
|
Returns:
| Type | Description |
|---|---|
str
|
The resolved model identifier (e.g. |
str
|
available, otherwise |