Script 'mail_helper' called by obssrc
Hello community,
here is the log from the commit of package python-langsmith for
openSUSE:Factory checked in at 2026-07-17 01:43:23
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-langsmith (Old)
and /work/SRC/openSUSE:Factory/.python-langsmith.new.24530 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-langsmith"
Fri Jul 17 01:43:23 2026 rev:10 rq:1366100 version:0.10.5
Changes:
--------
--- /work/SRC/openSUSE:Factory/python-langsmith/python-langsmith.changes
2026-07-15 16:57:50.839499239 +0200
+++
/work/SRC/openSUSE:Factory/.python-langsmith.new.24530/python-langsmith.changes
2026-07-17 01:44:05.782508310 +0200
@@ -1,0 +2,11 @@
+Thu Jul 16 05:54:10 UTC 2026 - Martin Pluskal <[email protected]>
+
+- Update to 0.10.5:
+ * Capture realtime/voice model costs for the optional OpenAI
+ Agents realtime and Google ADK Live (Gemini) integrations
+ (extras not enabled in this package)
+ * Capture the realtime user transcript for the optional LiveKit
+ voice integration
+ * Sync the bundled LangSmith backend API schema definitions
+
+-------------------------------------------------------------------
Old:
----
langsmith-0.10.3.tar.gz
New:
----
langsmith-0.10.5.tar.gz
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Other differences:
------------------
++++++ python-langsmith.spec ++++++
--- /var/tmp/diff_new_pack.uzb0uG/_old 2026-07-17 01:44:06.354527406 +0200
+++ /var/tmp/diff_new_pack.uzb0uG/_new 2026-07-17 01:44:06.358527539 +0200
@@ -17,7 +17,7 @@
Name: python-langsmith
-Version: 0.10.3
+Version: 0.10.5
Release: 0
Summary: Client library for the LangSmith LLM tracing and evaluation
platform
License: MIT
++++++ langsmith-0.10.3.tar.gz -> langsmith-0.10.5.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/langsmith-0.10.3/.bumpversion.cfg
new/langsmith-0.10.5/.bumpversion.cfg
--- old/langsmith-0.10.3/.bumpversion.cfg 2020-02-02 01:00:00.000000000
+0100
+++ new/langsmith-0.10.5/.bumpversion.cfg 2020-02-02 01:00:00.000000000
+0100
@@ -1,5 +1,5 @@
[bumpversion]
-current_version = 0.10.3
+current_version = 0.10.5
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)
serialize = {major}.{minor}.{patch}
search = {current_version}
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/langsmith-0.10.3/PKG-INFO
new/langsmith-0.10.5/PKG-INFO
--- old/langsmith-0.10.3/PKG-INFO 2020-02-02 01:00:00.000000000 +0100
+++ new/langsmith-0.10.5/PKG-INFO 2020-02-02 01:00:00.000000000 +0100
@@ -1,6 +1,6 @@
Metadata-Version: 2.4
Name: langsmith
-Version: 0.10.3
+Version: 0.10.5
Summary: Client library to connect to the LangSmith Observability and
Evaluation Platform.
Project-URL: Homepage, https://smith.langchain.com/
Project-URL: Documentation, https://docs.smith.langchain.com/
@@ -29,7 +29,7 @@
Requires-Dist: google-adk>=1.0.0; extra == 'google-adk'
Requires-Dist: wrapt>=1.16.0; extra == 'google-adk'
Provides-Extra: google-adk-live
-Requires-Dist: google-adk>=1.14.0; extra == 'google-adk-live'
+Requires-Dist: google-adk>=2.3.0; extra == 'google-adk-live'
Provides-Extra: langsmith-pyo3
Requires-Dist: langsmith-pyo3>=0.1.0rc2; extra == 'langsmith-pyo3'
Provides-Extra: livekit
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/langsmith-0.10.3/langsmith/__init__.py
new/langsmith-0.10.5/langsmith/__init__.py
--- old/langsmith-0.10.3/langsmith/__init__.py 2020-02-02 01:00:00.000000000
+0100
+++ new/langsmith-0.10.5/langsmith/__init__.py 2020-02-02 01:00:00.000000000
+0100
@@ -45,7 +45,7 @@
# Avoid calling into importlib on every call to __version__
-__version__ = "0.10.3"
+__version__ = "0.10.5"
version = __version__ # for backwards compatibility
# Metadata key to hide a traced run from LangSmith's Messages View.
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/langsmith-0.10.3/langsmith/_internal/voice/session.py
new/langsmith-0.10.5/langsmith/_internal/voice/session.py
--- old/langsmith-0.10.3/langsmith/_internal/voice/session.py 2020-02-02
01:00:00.000000000 +0100
+++ new/langsmith-0.10.5/langsmith/_internal/voice/session.py 2020-02-02
01:00:00.000000000 +0100
@@ -309,6 +309,11 @@
prompt* — the conversation transcript so far minus the response being
recorded (the trailing assistant message) — so the ``llm`` run reads
like
a normal model call instead of having empty inputs.
+
+ ``usage_metadata`` must be passed here, not patched on afterwards: cost
+ is derived when the run is finalized, so usage that lands on a later
+ patch (after ``end``) is not reflected. Services that report tokens on
a
+ separate, later event should defer this call until the counts arrive.
"""
parent = parent or self._current_turn or self.run
if inputs is None:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/langsmith-0.10.3/langsmith/_openapi_client/resources/sandboxes/boxes.py
new/langsmith-0.10.5/langsmith/_openapi_client/resources/sandboxes/boxes.py
--- old/langsmith-0.10.3/langsmith/_openapi_client/resources/sandboxes/boxes.py
2020-02-02 01:00:00.000000000 +0100
+++ new/langsmith-0.10.5/langsmith/_openapi_client/resources/sandboxes/boxes.py
2020-02-02 01:00:00.000000000 +0100
@@ -60,6 +60,7 @@
mem_bytes: int | Omit = omit,
mount_config: box_create_params.MountConfig | Omit = omit,
name: str | Omit = omit,
+ preserve_memory_on_stop: bool | Omit = omit,
proxy_config: box_create_params.ProxyConfig | Omit = omit,
restore_memory: bool | Omit = omit,
snapshot_id: str | Omit = omit,
@@ -83,6 +84,12 @@
vCPU); takes precedence over VCPUs. Fractional (sub-vCPU) values
are not
available for every sandbox.
+ preserve_memory_on_stop: PreserveMemoryOnStop, when true, suspends
the sandbox's memory on a voluntary
+ stop (idle timeout or explicit stop) so the next start resumes
from where it
+ left off. Default false discards memory and keeps only the
filesystem, so the
+ next start is a cold boot. Restarts triggered by infrastructure
maintenance
+ always preserve memory regardless of this setting.
+
restore_memory:
RestoreMemory selects how the sandbox handles a snapshot's
captured memory:
@@ -112,6 +119,7 @@
"mem_bytes": mem_bytes,
"mount_config": mount_config,
"name": name,
+ "preserve_memory_on_stop": preserve_memory_on_stop,
"proxy_config": proxy_config,
"restore_memory": restore_memory,
"snapshot_id": snapshot_id,
@@ -557,6 +565,7 @@
mem_bytes: int | Omit = omit,
mount_config: box_create_params.MountConfig | Omit = omit,
name: str | Omit = omit,
+ preserve_memory_on_stop: bool | Omit = omit,
proxy_config: box_create_params.ProxyConfig | Omit = omit,
restore_memory: bool | Omit = omit,
snapshot_id: str | Omit = omit,
@@ -580,6 +589,12 @@
vCPU); takes precedence over VCPUs. Fractional (sub-vCPU) values
are not
available for every sandbox.
+ preserve_memory_on_stop: PreserveMemoryOnStop, when true, suspends
the sandbox's memory on a voluntary
+ stop (idle timeout or explicit stop) so the next start resumes
from where it
+ left off. Default false discards memory and keeps only the
filesystem, so the
+ next start is a cold boot. Restarts triggered by infrastructure
maintenance
+ always preserve memory regardless of this setting.
+
restore_memory:
RestoreMemory selects how the sandbox handles a snapshot's
captured memory:
@@ -609,6 +624,7 @@
"mem_bytes": mem_bytes,
"mount_config": mount_config,
"name": name,
+ "preserve_memory_on_stop": preserve_memory_on_stop,
"proxy_config": proxy_config,
"restore_memory": restore_memory,
"snapshot_id": snapshot_id,
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/langsmith-0.10.3/langsmith/_openapi_client/resources/threads.py
new/langsmith-0.10.5/langsmith/_openapi_client/resources/threads.py
--- old/langsmith-0.10.3/langsmith/_openapi_client/resources/threads.py
2020-02-02 01:00:00.000000000 +0100
+++ new/langsmith-0.10.5/langsmith/_openapi_client/resources/threads.py
2020-02-02 01:00:00.000000000 +0100
@@ -71,6 +71,9 @@
"FIRST_TOKEN_TIME",
"INPUTS_PREVIEW",
"OUTPUTS_PREVIEW",
+ "INPUTS",
+ "OUTPUTS",
+ "ERROR",
"PROMPT_COST",
"COMPLETION_COST",
"TOTAL_COST",
@@ -339,6 +342,9 @@
"FIRST_TOKEN_TIME",
"INPUTS_PREVIEW",
"OUTPUTS_PREVIEW",
+ "INPUTS",
+ "OUTPUTS",
+ "ERROR",
"PROMPT_COST",
"COMPLETION_COST",
"TOTAL_COST",
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/langsmith-0.10.3/langsmith/_openapi_client/types/sandbox_response.py
new/langsmith-0.10.5/langsmith/_openapi_client/types/sandbox_response.py
--- old/langsmith-0.10.3/langsmith/_openapi_client/types/sandbox_response.py
2020-02-02 01:00:00.000000000 +0100
+++ new/langsmith-0.10.5/langsmith/_openapi_client/types/sandbox_response.py
2020-02-02 01:00:00.000000000 +0100
@@ -532,6 +532,8 @@
name: Optional[str] = None
+ preserve_memory_on_stop: Optional[bool] = None
+
proxy_config: Optional[ProxyConfig] = None
size_class: Optional[str] = None
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/langsmith-0.10.3/langsmith/_openapi_client/types/sandboxes/box_create_params.py
new/langsmith-0.10.5/langsmith/_openapi_client/types/sandboxes/box_create_params.py
---
old/langsmith-0.10.3/langsmith/_openapi_client/types/sandboxes/box_create_params.py
2020-02-02 01:00:00.000000000 +0100
+++
new/langsmith-0.10.5/langsmith/_openapi_client/types/sandboxes/box_create_params.py
2020-02-02 01:00:00.000000000 +0100
@@ -81,6 +81,15 @@
name: str
+ preserve_memory_on_stop: bool
+ """
+ PreserveMemoryOnStop, when true, suspends the sandbox's memory on a
voluntary
+ stop (idle timeout or explicit stop) so the next start resumes from where
it
+ left off. Default false discards memory and keeps only the filesystem, so
the
+ next start is a cold boot. Restarts triggered by infrastructure maintenance
+ always preserve memory regardless of this setting.
+ """
+
proxy_config: ProxyConfig
restore_memory: bool
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/langsmith-0.10.3/langsmith/_openapi_client/types/thread_list_traces_params.py
new/langsmith-0.10.5/langsmith/_openapi_client/types/thread_list_traces_params.py
---
old/langsmith-0.10.3/langsmith/_openapi_client/types/thread_list_traces_params.py
2020-02-02 01:00:00.000000000 +0100
+++
new/langsmith-0.10.5/langsmith/_openapi_client/types/thread_list_traces_params.py
2020-02-02 01:00:00.000000000 +0100
@@ -47,6 +47,9 @@
"FIRST_TOKEN_TIME",
"INPUTS_PREVIEW",
"OUTPUTS_PREVIEW",
+ "INPUTS",
+ "OUTPUTS",
+ "ERROR",
"PROMPT_COST",
"COMPLETION_COST",
"TOTAL_COST",
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/langsmith-0.10.3/langsmith/_openapi_client/types/thread_trace.py
new/langsmith-0.10.5/langsmith/_openapi_client/types/thread_trace.py
--- old/langsmith-0.10.3/langsmith/_openapi_client/types/thread_trace.py
2020-02-02 01:00:00.000000000 +0100
+++ new/langsmith-0.10.5/langsmith/_openapi_client/types/thread_trace.py
2020-02-02 01:00:00.000000000 +0100
@@ -76,6 +76,12 @@
JSON null if the run is still in progress. Omitted unless included in
`selects`.
"""
+ error: Optional[str] = None
+ """`error` is the full root run error message when the run failed.
+
+ Omitted unless included in `selects`.
+ """
+
error_preview: Optional[str] = None
"""`error_preview` is a short error summary when the run failed.
@@ -89,6 +95,12 @@
in `selects`.
"""
+ inputs: Optional[object] = None
+ """`inputs` is the full root run input payload.
+
+ Omitted unless included in `selects`.
+ """
+
inputs_preview: Optional[str] = None
"""`inputs_preview` is a truncated text preview of inputs.
@@ -117,6 +129,12 @@
included in `selects`.
"""
+ outputs: Optional[object] = None
+ """`outputs` is the full root run output payload.
+
+ Omitted unless included in `selects`.
+ """
+
outputs_preview: Optional[str] = None
"""`outputs_preview` is a truncated text preview of outputs.
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/langsmith-0.10.3/langsmith/integrations/google_adk_live/_plugin.py
new/langsmith-0.10.5/langsmith/integrations/google_adk_live/_plugin.py
--- old/langsmith-0.10.3/langsmith/integrations/google_adk_live/_plugin.py
2020-02-02 01:00:00.000000000 +0100
+++ new/langsmith-0.10.5/langsmith/integrations/google_adk_live/_plugin.py
2020-02-02 01:00:00.000000000 +0100
@@ -159,23 +159,70 @@
return getattr(model, "model", None) or None
-def _usage_metadata(event: Any) -> dict[str, int] | None:
+def _audio_tokens(details: Any) -> int | None:
+ """Sum the ``AUDIO`` modality token count from a ``ModalityTokenCount``
list.
+
+ Gemini reports per-modality breakdowns as a list of ``{modality,
token_count}``
+ entries; the audio bucket is what the cost engine prices at the (much
higher)
+ audio rate. ``MediaModality`` has a single audio member (``AUDIO``), so an
+ exact match is enough. Returns ``None`` when no audio modality is present.
+ """
+ if not isinstance(details, (list, tuple)):
+ return None
+ total = 0
+ found = False
+ for entry in details:
+ modality = getattr(entry, "modality", None)
+ modality = getattr(modality, "value", modality)
+ count = getattr(entry, "token_count", None)
+ if str(modality).upper() == "AUDIO" and isinstance(count, int):
+ total += count
+ found = True
+ return total if found else None
+
+
+def _usage_metadata(event: Any) -> dict[str, Any] | None:
"""Map an ADK event's ``usage_metadata`` to LangSmith token usage, if any.
- ADK inherits genai's ``GenerateContentResponseUsageMetadata`` (prompt /
- candidates / total token counts). Live events may carry no usage at all, in
- which case the ``llm`` span is still recorded without token counts. Only
+ ADK inherits genai's ``GenerateContentResponseUsageMetadata``. Beyond the
+ aggregate prompt / candidates / total counts, the ``audio`` modality split
+ (from ``prompt_tokens_details`` / ``candidates_tokens_details``), the
+ ``cache_read`` count, and the ``reasoning`` count are captured — for Gemini
+ Live the audio tokens dominate and are priced very differently from text,
so
+ dropping the split would mis-price the turn. Live events may carry no usage
+ at all, in which case the ``llm`` span is recorded without token counts.
Only
the fields ADK actually reports are included.
"""
um = getattr(event, "usage_metadata", None)
if um is None:
return None
- mapping = {
- "input_tokens": getattr(um, "prompt_token_count", None),
- "output_tokens": getattr(um, "candidates_token_count", None),
- "total_tokens": getattr(um, "total_token_count", None),
- }
- usage = {k: v for k, v in mapping.items() if isinstance(v, int)}
+ usage: dict[str, Any] = {}
+ for key, attr in (
+ ("input_tokens", "prompt_token_count"),
+ ("output_tokens", "candidates_token_count"),
+ ("total_tokens", "total_token_count"),
+ ):
+ if isinstance(v := getattr(um, attr, None), int):
+ usage[key] = v
+
+ input_details: dict[str, int] = {}
+ if (audio := _audio_tokens(getattr(um, "prompt_tokens_details", None))) is
not None:
+ input_details["audio"] = audio
+ if isinstance(cached := getattr(um, "cached_content_token_count", None),
int):
+ input_details["cache_read"] = cached
+ if input_details:
+ usage["input_token_details"] = input_details
+
+ output_details: dict[str, int] = {}
+ if (
+ audio := _audio_tokens(getattr(um, "candidates_tokens_details", None))
+ ) is not None:
+ output_details["audio"] = audio
+ if isinstance(reasoning := getattr(um, "thoughts_token_count", None), int):
+ output_details["reasoning"] = reasoning
+ if output_details:
+ usage["output_token_details"] = output_details
+
return usage or None
@@ -196,6 +243,17 @@
# FunctionCall.id (falling back to the tool name when ADK omits an id).
# A FIFO list per key disambiguates repeated/parallel identical calls.
self._open_tools: dict[str, list[RunTree]] = {}
+ # ADK reports token usage on its own event, separate from (and just
+ # after) the agent transcript, and before turn_complete. The priced
+ # ``output_transcription`` llm span must carry that usage on the run's
+ # finalizing patch (cost is derived at finalize, not from a later
+ # patch), so the transcript is held here until its usage arrives — then
+ # both are recorded in one shot. ``_pending_event`` is the raw agent
+ # event (kept for the span's raw_event metadata). ``_pending_usage``
+ # covers the rarer case where usage lands before the transcript.
+ self._pending_text: str | None = None
+ self._pending_event: Any = None
+ self._pending_usage: dict[str, Any] | None = None
@property
def session(self) -> EventSession:
@@ -233,29 +291,37 @@
for response in view.function_responses:
self._end_tool(response, event, t)
- # A finalized agent utterance: transcript rollup plus a point-in-time
- # ``output_transcription`` span (raw event payload, token usage when
ADK
- # reports it). Anchored at the event's arrival and left flat rather
than
- # grouped into a synthetic turn: Gemini finalizes transcripts late
(often
- # after the audio it describes), so a turn's boundaries can't be placed
- # against the audio timeline without reading as misaligned.
+ # A finalized agent utterance. ADK reports its token usage on a
+ # *separate*, later event (before turn_complete), and cost is derived
+ # when the ``llm`` run is finalized — so usage patched on after the run
+ # ends is lost. The transcript is therefore held until its usage
+ # arrives, then recorded in one shot (see ``_emit_agent_turn``). Flat
+ # and point-in-time, not turn-grouped: Gemini finalizes transcripts
late
+ # (often after the audio they describe), so turn boundaries can't be
+ # placed against the audio timeline without reading as misaligned.
+ um = _usage_metadata(event)
fa = view.final_agent_transcript
if fa:
self._trace.add_message("assistant", fa)
- # llm-kind (not chain) so LangSmith prices it; ls_provider +
- # ls_model_name let it match a Gemini pricing entry. Still flat and
- # point-in-time at event arrival — no turn grouping. Raw event kept
- # in metadata so nothing is lost.
- self._trace.record_llm(
- name="output_transcription",
- outputs={"role": "assistant", "content": fa},
- usage_metadata=_usage_metadata(event),
- metadata={
- "raw_event": scrub(dump_event(event)),
- "ls_provider": "google",
- "ls_model_name": self._model,
- },
- )
+ if um is not None:
+ # Usage already on this event — record immediately.
+ self._emit_agent_turn(event, fa, um)
+ elif self._pending_usage is not None:
+ # Usage arrived first this turn — pair them now.
+ self._emit_agent_turn(event, fa, self._pending_usage)
+ self._pending_usage = None
+ else:
+ # Hold for the usage event that follows within the turn.
+ self._flush_pending_agent_turn()
+ self._pending_text = fa
+ self._pending_event = event
+ elif um is not None:
+ if self._pending_text is not None:
+ self._emit_agent_turn(self._pending_event, self._pending_text,
um)
+ self._pending_text = None
+ self._pending_event = None
+ else:
+ self._pending_usage = um
# Barge-in and end-of-turn markers, each a point-in-time span.
if view.interrupted:
@@ -264,6 +330,40 @@
if view.turn_complete:
with self._trace.event_span(event, t, name="turn_complete",
inbound=False):
pass
+ # Turn boundary: a held transcript never got usage — record it
as-is
+ # rather than lose it. Drop stray usage that never found a
transcript
+ # so it can't attach to the next turn.
+ self._flush_pending_agent_turn()
+ self._pending_usage = None
+
+ def _emit_agent_turn(
+ self, event: Any, text: str, usage: dict[str, Any] | None
+ ) -> None:
+ """Record one ``output_transcription`` ``llm`` run with its usage.
+
+ llm-kind (not chain) so LangSmith prices it; ls_provider +
ls_model_name
+ let it match a Gemini pricing entry. ``usage`` is passed in (not
patched
+ later) so it lands on the run's finalizing patch. Raw event kept in
+ metadata so nothing is lost.
+ """
+ self._trace.record_llm(
+ name="output_transcription",
+ outputs={"role": "assistant", "content": text},
+ usage_metadata=usage,
+ metadata={
+ "raw_event": scrub(dump_event(event)),
+ "ls_provider": "google",
+ "ls_model_name": self._model,
+ },
+ )
+ logger.debug("ADK Live: recorded output_transcription (usage=%s)",
usage)
+
+ def _flush_pending_agent_turn(self) -> None:
+ """Record a held transcript that never received usage (usage stays
None)."""
+ if self._pending_text is not None:
+ self._emit_agent_turn(self._pending_event, self._pending_text,
None)
+ self._pending_text = None
+ self._pending_event = None
def _start_tool(self, call: Any, event: Any) -> None:
"""Open a held-open ``tool`` span for one ``function_call``.
@@ -328,12 +428,15 @@
self._open_tools = {k: v for k, v in self._open_tools.items() if v}
def flush_pending(self) -> None:
- """Close any tool span that never saw its response, with an error.
+ """Close anything left open at teardown.
- Called at teardown: a tool that raised (ADK routes that through its own
- error handling, not a function_response event) or a session torn down
- mid-call would otherwise leave a span dangling open forever.
+ A held agent transcript whose turn never completed (e.g. a Ctrl-C
+ mid-turn) is recorded without usage rather than dropped. A tool that
+ raised (ADK routes that through its own error handling, not a
+ function_response event) or a session torn down mid-call would
otherwise
+ leave a span dangling open forever, so each is closed with an error.
"""
+ self._flush_pending_agent_turn()
for queue in self._open_tools.values():
for run in queue:
run.error = "tool did not complete before the session ended"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/langsmith-0.10.3/langsmith/integrations/livekit/__init__.py
new/langsmith-0.10.5/langsmith/integrations/livekit/__init__.py
--- old/langsmith-0.10.3/langsmith/integrations/livekit/__init__.py
2020-02-02 01:00:00.000000000 +0100
+++ new/langsmith-0.10.5/langsmith/integrations/livekit/__init__.py
2020-02-02 01:00:00.000000000 +0100
@@ -48,6 +48,11 @@
and applies it to every span in the trace — so it holds even for spans
finished on a background task, and concurrent conversations stay separated.
+ For a realtime (speech-to-speech) model, also call
+ :meth:`LiveKitLangSmithSpanProcessor.instrument_session` on the returned
+ processor: the user transcript arrives as a session event rather than on a
+ span, so without it the trace shows only the agent's turns.
+
Args:
audio_path_provider: zero-arg callable returning a local recording path
whose bytes are embedded in the root span (console/dev only). For
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/langsmith-0.10.3/langsmith/integrations/livekit/processor.py
new/langsmith-0.10.5/langsmith/integrations/livekit/processor.py
--- old/langsmith-0.10.3/langsmith/integrations/livekit/processor.py
2020-02-02 01:00:00.000000000 +0100
+++ new/langsmith-0.10.5/langsmith/integrations/livekit/processor.py
2020-02-02 01:00:00.000000000 +0100
@@ -53,6 +53,7 @@
_SESSION_SPAN = "agent_session"
_TOOL_SPAN = "function_tool"
_REALTIME_METRICS_SPAN = "realtime_metrics"
+_USER_SPEAKING_SPAN = "user_speaking"
# ``llm_request`` emits one ``gen_ai.<role>.message`` event per chat item,
plus a
# ``gen_ai.choice`` for the reply.
@@ -103,7 +104,9 @@
return TTLCache(maxsize=DEFAULT_STATE_MAXSIZE,
ttl=state_ttl_seconds)
# trace_id -> running transcript, rolled up onto the root at session
end.
- self._transcript_by_trace: MutableMapping[int, list[dict]] = _cache()
+ self._transcript_by_trace: MutableMapping[int, list[tuple[Any, dict]]]
= (
+ _cache()
+ )
# trace_id -> root span held open until the session (and any egress)
ends.
self._deferred_root_by_trace: MutableMapping[int, TranslatedSpan] =
_cache()
# trace_ids whose ``agent_session`` end span has arrived (used as a
set).
@@ -114,12 +117,49 @@
self._pending_audio_by_thread: MutableMapping[str, dict] = _cache()
# thread id -> trace_id, so ``complete_recording`` can find the trace.
self._trace_by_thread: MutableMapping[str, int] = _cache()
+ self._deferred_user_speaking: MutableMapping[str,
list[TranslatedSpan]] = (
+ _cache()
+ )
+ self._pending_user_transcripts: MutableMapping[str, list[str]] =
_cache()
def _remember_thread_id(self, trace_id: int, thread_id: str) -> None:
"""Also index thread→trace (so ``complete_recording`` can find the
trace)."""
super()._remember_thread_id(trace_id, thread_id)
self._trace_by_thread[thread_id] = trace_id
+ # -- realtime session instrumentation ------------------------------------
+
+ def instrument_session(self, session: Any, thread_id: str) -> None:
+ """Subscribe this processor to a LiveKit ``AgentSession``'s events.
+
+ A realtime (speech-to-speech) model's user transcript arrives via the
+ ``user_input_transcribed`` session event — never on a span — so the
+ processor can't see it from spans alone, and the trace ends up with
only
+ the agent's turns. Call this once after creating the session to wire
the
+ transcript in::
+
+ processor = configure_livekit(...)
+ session = AgentSession(llm=...)
+ set_thread_id(conversation_id)
+ processor.instrument_session(session, conversation_id)
+
+ Each final transcript is paired FIFO with the next ``user_speaking``
span
+ that has no transcript yet (we have no id to match a transcript to its
+ exact span). No-op for the cascade pipeline, where the transcript
already
+ rides the STT ``user_turn`` span.
+
+ Args:
+ session: the LiveKit ``AgentSession`` to subscribe to.
+ thread_id: the conversation id, matching :func:`set_thread_id`.
+ """
+
+ @session.on("user_input_transcribed")
+ def _on_user_input_transcribed(ev: Any) -> None:
+ if getattr(ev, "is_final", False):
+ self._record_user_transcript(
+ str(thread_id), getattr(ev, "transcript", "") or ""
+ )
+
# -- production recording (egress) ---------------------------------------
def expect_recording(self, thread_id: str) -> None:
@@ -173,10 +213,13 @@
tspan.set_kind("chain") # wrappers: no fabricated I/O
elif name == _TURN_SPAN:
self._handle_turn(tspan)
+ elif name == _USER_SPEAKING_SPAN:
+ return self._handle_user_speaking(tspan)
elif name == _SESSION_SPAN:
# Session end: release the deferred root, then export this span.
tspan.set_kind("chain")
self._ended_session_traces[trace_id] = True
+ self._flush_user_speaking(self._thread_id_by_trace.get(trace_id))
self._maybe_release(trace_id)
elif name == "eou_detection":
tspan.set_kind("chain") # framework step
@@ -280,18 +323,99 @@
user_input = tspan.attributes.get("lk.user_input")
response = tspan.attributes.get("lk.response.text")
trace_id = tspan.span.context.trace_id
- conversation = self._transcript_by_trace.get(trace_id) or []
+ start = tspan.span.start_time
if user_input:
msg = build_user_message(str(user_input))
tspan.set_messages(prompt=[msg])
- conversation.append(msg)
+ self._append_transcript(trace_id, msg, start)
if response:
msg = build_assistant_message(str(response))
tspan.set_messages(completion=[msg])
- conversation.append(msg)
+ self._append_transcript(trace_id, msg, start)
+
+ def _append_transcript(self, trace_id: int, message: dict, sort_key: Any)
-> None:
+ """Append a message to the transcript the root rolls up, keyed for
ordering."""
+ conversation = self._transcript_by_trace.get(trace_id) or []
+ conversation.append((sort_key, message))
# Re-assign (not just mutate) so each turn refreshes the cache TTL.
- if conversation:
- self._transcript_by_trace[trace_id] = conversation
+ self._transcript_by_trace[trace_id] = conversation
+
+ # -- realtime user transcript (speech-to-speech) --------------------------
+
+ def _handle_user_speaking(self, tspan: TranslatedSpan) -> bool:
+ """Handle a ``user_speaking`` span — the realtime user turn.
+
+ Deferred (``False``): stamp+export now if the transcript was already
+ buffered, else hold it until one is fed (or flushed untouched at
session
+ end). Exported as-is (``True``) when there's no thread id to pair
against.
+ """
+ tspan.set_kind("chain")
+ thread = tspan.attributes.get("langsmith.metadata.thread_id")
+ if thread is None:
+ return True
+ thread = str(thread)
+
+ pending = self._pending_user_transcripts.get(thread)
+ if pending:
+ transcript = pending.pop(0)
+ if pending:
+ self._pending_user_transcripts[thread] = pending
+ else:
+ self._pending_user_transcripts.pop(thread, None)
+ self._apply_user_transcript(tspan, transcript)
+ self._export(tspan)
+ return False
+
+ held = self._deferred_user_speaking.get(thread) or []
+ held.append(tspan)
+ self._deferred_user_speaking[thread] = held
+ return False
+
+ def _record_user_transcript(self, thread_id: str, transcript: str) -> None:
+ """Pair a realtime transcript (from ``instrument_session``) with its
span.
+
+ Applies it to the oldest held ``user_speaking`` span for the thread, or
+ buffers it if that span hasn't ended yet.
+ """
+ tid = str(thread_id)
+ held = self._deferred_user_speaking.get(tid)
+ if held:
+ tspan = held.pop(0)
+ if held:
+ self._deferred_user_speaking[tid] = held
+ else:
+ self._deferred_user_speaking.pop(tid, None)
+ self._apply_user_transcript(tspan, transcript)
+ self._export(tspan)
+ return
+ pending = self._pending_user_transcripts.get(tid) or []
+ pending.append(transcript)
+ self._pending_user_transcripts[tid] = pending
+
+ def _apply_user_transcript(self, tspan: TranslatedSpan, transcript: str)
-> None:
+ """Render a fed transcript onto a ``user_speaking`` span as the user's
turn.
+
+ Unlike the cascade STT ``user_turn`` (which is excluded), this is the
only
+ record of the realtime user's words, so it's shown — as a plain
``user``
+ message. An empty transcript renders no fabricated I/O.
+ """
+ tspan.set_kind("llm")
+ if transcript:
+ tspan.attributes["lk.user_transcript"] = transcript
+ msg = build_user_message(transcript)
+ tspan.set_messages(prompt=[msg])
+ self._append_transcript(
+ tspan.span.context.trace_id, msg, tspan.span.start_time
+ )
+
+ def _flush_user_speaking(self, thread_id: Optional[str]) -> None:
+ """Export held ``user_speaking`` spans untranscribed (no transcript
arrived)."""
+ if thread_id is None:
+ return
+ tid = str(thread_id)
+ for tspan in self._deferred_user_speaking.pop(tid, None) or []:
+ self._export(tspan)
+ self._pending_user_transcripts.pop(tid, None)
def _handle_tool(self, tspan: TranslatedSpan) -> None:
"""``function_tool``: render as a proper ``tool`` run with its I/O."""
@@ -347,10 +471,11 @@
self._cleanup_trace(trace_id)
def _render_conversation(self, tspan: TranslatedSpan) -> bool:
- """Set the whole transcript as the root's input messages; return if
any."""
- messages = self._transcript_by_trace.get(tspan.span.context.trace_id,
[])
- if not messages:
+ """Set the whole transcript (ordered by span start_time) as the root's
input."""
+ entries = self._transcript_by_trace.get(tspan.span.context.trace_id,
[])
+ if not entries:
return False
+ messages = [msg for _, msg in sorted(entries, key=lambda e: e[0])]
tspan.set_messages(prompt=messages)
return True
@@ -364,15 +489,18 @@
self._trace_by_thread.pop(thread, None)
self._threads_awaiting_recording.pop(thread, None)
self._pending_audio_by_thread.pop(thread, None)
+ self._flush_user_speaking(thread)
def shutdown(self) -> None:
- """Force-export any still-held roots (last resort), then shut down.
+ """Force-export any still-held roots and user_speaking spans, then
shut down.
``force_flush`` deliberately does not — a still-held root there is
legitimately in progress, not a buffered export waiting to drain.
"""
for trace_id in list(self._deferred_root_by_trace):
self._maybe_release(trace_id, force=True)
+ for thread in list(self._deferred_user_speaking.keys()):
+ self._flush_user_speaking(thread)
super().shutdown()
def force_flush(self, timeout_millis: int = 30000) -> bool:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/langsmith-0.10.3/langsmith/integrations/openai_realtime/_connection.py
new/langsmith-0.10.5/langsmith/integrations/openai_realtime/_connection.py
--- old/langsmith-0.10.3/langsmith/integrations/openai_realtime/_connection.py
2020-02-02 01:00:00.000000000 +0100
+++ new/langsmith-0.10.5/langsmith/integrations/openai_realtime/_connection.py
2020-02-02 01:00:00.000000000 +0100
@@ -156,15 +156,14 @@
return base
-def response_usage_metadata(response: Any) -> dict[str, Any] | None:
- """Map Realtime token ``usage`` onto LangSmith ``usage_metadata`` (for
cost).
+def usage_metadata_from_usage(usage: Any) -> dict[str, Any] | None:
+ """Map a Realtime ``usage`` (object or dict) onto LangSmith
``usage_metadata``.
Delegates to the shared OpenAI usage mapper so the Realtime integration and
the Chat/Agents wrappers agree on the canonical shape (and so audio/cached
- token detail is captured, not dropped). Returns ``None`` when the response
- carries no usage (e.g. a cancelled turn).
+ token detail is captured, not dropped). Returns ``None`` when there is no
+ usage or it carries no token counts (e.g. a cancelled turn).
"""
- usage = getattr(response, "usage", None)
if usage is None:
return None
usage_dict = _usage_to_dict(usage)
@@ -176,6 +175,11 @@
return dict(_create_usage_metadata(usage_dict))
+def response_usage_metadata(response: Any) -> dict[str, Any] | None:
+ """Map a ``response.done`` payload's token ``usage`` onto
``usage_metadata``."""
+ return usage_metadata_from_usage(getattr(response, "usage", None))
+
+
class _RealtimeTracer:
"""Turns the Realtime event stream into a LangSmith trace, one event at a
time.
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/langsmith-0.10.3/langsmith/integrations/openai_realtime/_session.py
new/langsmith-0.10.5/langsmith/integrations/openai_realtime/_session.py
--- old/langsmith-0.10.3/langsmith/integrations/openai_realtime/_session.py
2020-02-02 01:00:00.000000000 +0100
+++ new/langsmith-0.10.5/langsmith/integrations/openai_realtime/_session.py
2020-02-02 01:00:00.000000000 +0100
@@ -15,8 +15,11 @@
curated ``user_message`` or an assistant ``model`` ``llm`` span), grouped into
turns. Each SDK-run tool becomes a single ``tool`` span spanning its
``tool_start``→``tool_end`` pair (so the span duration is the real tool
latency);
-``audio_interrupted`` flags the turn. (Token usage isn't available — the SDK
has
-no per-response usage events yet.)
+``audio_interrupted`` flags the turn. Token usage — which the SDK exposes no
+*semantic* event for — is recovered from the raw ``response.done`` the SDK
still
+forwards as a ``raw_model_event`` (``raw_server_event``), then attached to that
+turn's assistant ``model`` span so the conversation is priced (audio tokens
+included).
Trace shape — one conversation = one trace::
@@ -43,6 +46,9 @@
EventSession,
start_session,
)
+from langsmith.integrations.openai_realtime._connection import (
+ usage_metadata_from_usage,
+)
from langsmith.run_helpers import tracing_context
if TYPE_CHECKING:
@@ -175,6 +181,25 @@
return getattr(data, "item_id", None), transcript
+def raw_response_usage(event: Any) -> dict[str, Any] | None:
+ """Token usage from a ``raw_model_event`` wrapping a ``response.done``.
+
+ The Agents SDK emits no semantic usage event, but still forwards the raw
+ ``response.done`` (as a ``raw_server_event``), which carries
``response.usage``
+ — the only place per-turn token counts appear on this backend. Maps it via
the
+ shared Realtime usage mapper (so audio/cached detail is kept). Returns
``None``
+ for any other event or a payload without usage.
+ """
+ data = getattr(event, "data", None)
+ raw = getattr(data, "data", None)
+ if not isinstance(raw, dict) or raw.get("type") != "response.done":
+ return None
+ response = raw.get("response")
+ if not isinstance(response, dict):
+ return None
+ return usage_metadata_from_usage(response.get("usage"))
+
+
def history_item(event: Any) -> tuple[str | None, str | None, str | None]:
"""``(item_id, role, text)`` for a ``history_added`` event's single
item."""
item = getattr(event, "item", None)
@@ -278,6 +303,9 @@
self._notified: set[tuple[str, str]] = set() # (role, text) already
sent out
# Per-turn latency: when the current user turn opened; None = not
armed.
self._latency_since: float | None = None
+ # Usage from the most recent raw response.done, awaiting the assistant
+ # model span it belongs to (see observe / _record_item). None = none
seen.
+ self._pending_usage: dict[str, Any] | None = None
# Open tool spans, keyed by (tool_name, arguments). The SDK's
# tool_start/tool_end events carry no call_id, so start→end is matched
on
# that pair; a FIFO list per key disambiguates the (rare) concurrent or
@@ -295,6 +323,8 @@
self.record_first_audio(received_at)
return
if etype == "raw_model_event":
+ if (usage := raw_response_usage(event)) is not None:
+ self._pending_usage = usage
rec = raw_input_transcript(event)
if rec:
item_id, transcript = rec
@@ -464,8 +494,10 @@
else:
self._trace.record_llm(
outputs={"role": "assistant", "content": text},
+ usage_metadata=self._pending_usage,
metadata={"ls_provider": "openai", "ls_model_name":
self._model},
)
+ self._pending_usage = None
class _TracedRealtimeSession:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/langsmith-0.10.3/pyproject.toml
new/langsmith-0.10.5/pyproject.toml
--- old/langsmith-0.10.3/pyproject.toml 2020-02-02 01:00:00.000000000 +0100
+++ new/langsmith-0.10.5/pyproject.toml 2020-02-02 01:00:00.000000000 +0100
@@ -96,9 +96,12 @@
google-adk = ["google-adk>=1.0.0", "wrapt>=1.16.0"]
# Enabled via `pip install langsmith[google-adk-live]`
-# ADK Live (Gemini Live voice) tracing via LangSmithGoogleADKLivePlugin.
Requires the
-# plugin API (``google.adk.plugins.base_plugin``), first shipped in 1.7.0.
-google-adk-live = ["google-adk>=1.14.0"]
+# ADK Live (Gemini Live voice) tracing via LangSmithGoogleADKLivePlugin. Needs
the
+# plugin API (``google.adk.plugins.base_plugin``, first shipped in 1.7.0); the
+# >=2.3.0 floor is for model-cost capture — earlier releases pass Gemini's live
+# usage through under `response_token_count`, which is dropped when coerced to
+# the response type, silently losing output-token counts. 2.3.0 remaps it.
+google-adk-live = ["google-adk>=2.3.0"]
# Enabled via `pip install langsmith[strands-agents]`
strands-agents = [
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/langsmith-0.10.3/tests/unit_tests/wrappers/test_google_adk_live.py
new/langsmith-0.10.5/tests/unit_tests/wrappers/test_google_adk_live.py
--- old/langsmith-0.10.3/tests/unit_tests/wrappers/test_google_adk_live.py
2020-02-02 01:00:00.000000000 +0100
+++ new/langsmith-0.10.5/tests/unit_tests/wrappers/test_google_adk_live.py
2020-02-02 01:00:00.000000000 +0100
@@ -264,6 +264,45 @@
)
assert _usage_metadata(_event(usage_metadata=um)) == {"input_tokens":
10}
+ def test_captures_audio_cache_and_reasoning_detail(self):
+ # Gemini Live's dominant cost is audio tokens, split out by modality;
the
+ # audio bucket, cache_read, and reasoning must all be captured or the
+ # audio gets mis-priced as text.
+ def _mtc(modality, count):
+ return SimpleNamespace(
+ modality=SimpleNamespace(value=modality), token_count=count
+ )
+
+ um = SimpleNamespace(
+ prompt_token_count=100,
+ candidates_token_count=40,
+ total_token_count=140,
+ cached_content_token_count=15,
+ thoughts_token_count=8,
+ prompt_tokens_details=[_mtc("AUDIO", 90), _mtc("TEXT", 10)],
+ candidates_tokens_details=[_mtc("AUDIO", 35)],
+ )
+ assert _usage_metadata(_event(usage_metadata=um)) == {
+ "input_tokens": 100,
+ "output_tokens": 40,
+ "total_tokens": 140,
+ "input_token_details": {"audio": 90, "cache_read": 15},
+ "output_token_details": {"audio": 35, "reasoning": 8},
+ }
+
+ def test_audio_modality_as_plain_string(self):
+ # The modality can arrive as a bare string rather than an enum.
+ um = SimpleNamespace(
+ prompt_token_count=50,
+ prompt_tokens_details=[
+ SimpleNamespace(modality="AUDIO", token_count=50),
+ ],
+ )
+ assert _usage_metadata(_event(usage_metadata=um)) == {
+ "input_tokens": 50,
+ "input_token_details": {"audio": 50},
+ }
+
# --------------------------------------------------------------------------- #
# _session_key — per-conversation key precedence.
@@ -445,25 +484,27 @@
session.add_message.assert_not_called()
session.event_span.assert_not_called()
- def test_final_agent_transcript_records_llm_output_transcription(self,
make_plugin):
- # The agent response is a flat, point-in-time ``llm`` run named
- # ``output_transcription`` (so LangSmith prices it), carrying token
usage
- # and ls_provider/ls_model_name — not a chain event span, not
turn-grouped.
+ def test_transcript_then_usage_records_one_priced_llm_run(self,
make_plugin):
+ # ADK reports usage on a separate event just after the transcript. The
+ # transcript is held until that usage arrives, then recorded once as a
+ # flat ``output_transcription`` llm run carrying the usage — so it
lands
+ # on the run's finalizing patch (patching usage after the run ends is
+ # not reflected in cost). Not a chain event span, not turn-grouped.
plugin, ctx, session = self._start(make_plugin)
- um = SimpleNamespace(
- prompt_token_count=3, candidates_token_count=2, total_token_count=5
- )
self._emit(
- plugin,
- ctx,
- _event(
- output_transcription=_txn("sunny", finished=True),
usage_metadata=um
- ),
+ plugin, ctx, _event(output_transcription=_txn("sunny",
finished=True))
)
+ # Held, not yet recorded — waiting for its usage.
session.add_message.assert_called_once_with("assistant", "sunny")
session.set_title.assert_not_called() # title only set from user
speech
session.event_span.assert_not_called() # llm run, not a chain event
span
session.start_turn.assert_not_called()
+ session.record_llm.assert_not_called()
+
+ um = SimpleNamespace(
+ prompt_token_count=3, candidates_token_count=2, total_token_count=5
+ )
+ self._emit(plugin, ctx, _event(usage_metadata=um))
session.record_llm.assert_called_once()
kwargs = session.record_llm.call_args.kwargs
assert kwargs["name"] == "output_transcription"
@@ -476,21 +517,95 @@
assert kwargs["metadata"]["ls_provider"] == "google"
assert "ls_model_name" in kwargs["metadata"]
+ def test_usage_on_same_event_records_immediately(self, make_plugin):
+ # If usage happens to ride the transcript event, record in one shot.
+ plugin, ctx, session = self._start(make_plugin)
+ um = SimpleNamespace(
+ prompt_token_count=3, candidates_token_count=2, total_token_count=5
+ )
+ self._emit(
+ plugin,
+ ctx,
+ _event(
+ output_transcription=_txn("sunny", finished=True),
usage_metadata=um
+ ),
+ )
+ session.record_llm.assert_called_once()
+ assert session.record_llm.call_args.kwargs["usage_metadata"] == {
+ "input_tokens": 3,
+ "output_tokens": 2,
+ "total_tokens": 5,
+ }
+
def test_agent_model_is_resolved_onto_llm_span(self, make_plugin):
# The run's agent model becomes ls_model_name for cost lookup.
factory, created = make_plugin
plugin = factory()
ctx = _ctx(session_id="A", model="gemini-2.0-flash-live")
_run(plugin.before_run_callback(invocation_context=ctx))
+ um = SimpleNamespace(
+ prompt_token_count=3, candidates_token_count=2, total_token_count=5
+ )
_run(
plugin.on_event_callback(
invocation_context=ctx,
- event=_event(output_transcription=_txn("hi", finished=True)),
+ event=_event(
+ output_transcription=_txn("hi", finished=True),
usage_metadata=um
+ ),
)
)
meta = created[0].record_llm.call_args.kwargs["metadata"]
assert meta["ls_model_name"] == "gemini-2.0-flash-live"
+ def test_usage_before_transcript_is_paired_when_transcript_arrives(
+ self, make_plugin
+ ):
+ # Usage can also arrive before the transcript within a turn; it's held
+ # and paired when the transcript lands, still recorded in one shot.
+ plugin, ctx, session = self._start(make_plugin)
+ um = SimpleNamespace(
+ prompt_token_count=7, candidates_token_count=1, total_token_count=8
+ )
+ self._emit(plugin, ctx, _event(usage_metadata=um))
+ session.record_llm.assert_not_called() # nothing to record yet
+
+ self._emit(plugin, ctx, _event(output_transcription=_txn("hi",
finished=True)))
+ session.record_llm.assert_called_once()
+ assert session.record_llm.call_args.kwargs["usage_metadata"] == {
+ "input_tokens": 7,
+ "output_tokens": 1,
+ "total_tokens": 8,
+ }
+
+ def test_turn_complete_flushes_transcript_that_got_no_usage(self,
make_plugin):
+ # A held transcript whose turn ends with no usage is recorded as-is
+ # (usage=None) rather than dropped.
+ plugin, ctx, session = self._start(make_plugin)
+ self._emit(
+ plugin, ctx, _event(output_transcription=_txn("sunny",
finished=True))
+ )
+ session.record_llm.assert_not_called() # held, awaiting usage
+ self._emit(plugin, ctx, _event(turn_complete=True))
+ session.record_llm.assert_called_once()
+ assert session.record_llm.call_args.kwargs["usage_metadata"] is None
+
+ def test_stray_usage_after_turn_complete_is_not_recorded(self,
make_plugin):
+ # Usage in a turn that never produced a transcript is dropped, not
+ # misattributed to a later turn.
+ plugin, ctx, session = self._start(make_plugin)
+ self._emit(
+ plugin, ctx, _event(output_transcription=_txn("sunny",
finished=True))
+ )
+ self._emit(plugin, ctx, _event(turn_complete=True)) # flushes "sunny"
+ session.record_llm.reset_mock()
+
+ um = SimpleNamespace(
+ prompt_token_count=3, candidates_token_count=2, total_token_count=5
+ )
+ self._emit(plugin, ctx, _event(usage_metadata=um))
+ self._emit(plugin, ctx, _event(turn_complete=True))
+ session.record_llm.assert_not_called()
+
def test_tool_call_is_one_span_start_to_end(self, make_plugin):
# function_call opens a held-open ``tool`` span; the matching
# function_response closes it (real tool latency = the gap between).
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/langsmith-0.10.3/tests/unit_tests/wrappers/test_livekit.py
new/langsmith-0.10.5/tests/unit_tests/wrappers/test_livekit.py
--- old/langsmith-0.10.3/tests/unit_tests/wrappers/test_livekit.py
2020-02-02 01:00:00.000000000 +0100
+++ new/langsmith-0.10.5/tests/unit_tests/wrappers/test_livekit.py
2020-02-02 01:00:00.000000000 +0100
@@ -27,6 +27,7 @@
span_id=0x1,
parent=True,
scope="livekit-agents",
+ start_time=None,
):
"""Build a mock LiveKit span.
@@ -46,6 +47,9 @@
span.context = MagicMock()
span.context.trace_id = trace_id
span.context.span_id = span_id
+ # start_time (ns) is the root's conversation-ordering key; default to
span_id
+ # so spans sort in a stable, explicit order without every test setting it.
+ span.start_time = span_id if start_time is None else start_time
span.parent = MagicMock() if parent else None
span.instrumentation_scope = MagicMock()
span.instrumentation_scope.name = scope
@@ -132,6 +136,296 @@
assert len(proc._transcript_by_trace) == 0
+class TestRealtimeUserTranscript:
+ """Realtime (speech-to-speech) user transcripts fed via instrument_session.
+
+ In the realtime pipeline there is no STT ``user_turn`` span; LiveKit emits
a
+ bare ``user_speaking`` span and delivers the transcript out of band via the
+ ``user_input_transcribed`` event. The host forwards it to the processor,
which
+ holds the span until the transcript arrives, then stamps it on.
+ """
+
+ def teardown_method(self):
+ set_thread_id(None)
+
+ def _speaking(self, *, thread="call-1", trace_id=0xABC, span_id=0x2):
+ return _make_span(
+ "user_speaking",
+ {"langsmith.metadata.thread_id": thread},
+ trace_id=trace_id,
+ span_id=span_id,
+ )
+
+ def _exported(self, proc, name):
+ return [
+ c.args[0]
+ for c in proc.downstream.on_end.call_args_list
+ if c.args[0].name == name
+ ]
+
+ def test_transcript_after_span_stamps_and_exports(self):
+ # Span ends empty first (held), then the transcript arrives.
+ proc = _processor()
+ proc.on_end(self._speaking())
+ # Held — nothing exported yet.
+ assert self._exported(proc, "user_speaking") == []
+
+ proc._record_user_transcript("call-1", "what's the weather?")
+
+ exported = self._exported(proc, "user_speaking")
+ assert len(exported) == 1
+ span = exported[0]
+ assert span._attributes["lk.user_transcript"] == "what's the weather?"
+ assert span._attributes["langsmith.span.kind"] == "llm"
+ # Rendered as the user's turn (not excluded, not attributed to
assistant).
+ assert "langsmith.metadata.ls_message_view_exclude" not in
span._attributes
+ assert json.loads(span._attributes["gen_ai.prompt"])["messages"][0] ==
{
+ "role": "user",
+ "content": "what's the weather?",
+ }
+ assert "gen_ai.completion" not in span._attributes
+ # No state left behind.
+ assert len(proc._deferred_user_speaking) == 0
+ assert len(proc._pending_user_transcripts) == 0
+
+ def test_transcript_before_span_is_buffered(self):
+ # Transcript can race ahead of the span's on_end — buffer, then apply.
+ proc = _processor()
+ proc._record_user_transcript("call-1", "hello there")
+ assert self._exported(proc, "user_speaking") == []
+
+ proc.on_end(self._speaking())
+
+ exported = self._exported(proc, "user_speaking")
+ assert len(exported) == 1
+ assert exported[0]._attributes["lk.user_transcript"] == "hello there"
+ assert len(proc._pending_user_transcripts) == 0
+
+ def test_fifo_pairing_within_conversation(self):
+ # Two utterances, two transcripts — paired in order.
+ proc = _processor()
+ proc.on_end(self._speaking(span_id=0x2))
+ proc.on_end(self._speaking(span_id=0x3))
+ proc._record_user_transcript("call-1", "first")
+ proc._record_user_transcript("call-1", "second")
+
+ transcripts = [
+ s._attributes["lk.user_transcript"]
+ for s in self._exported(proc, "user_speaking")
+ ]
+ assert transcripts == ["first", "second"]
+
+ def test_no_thread_id_exports_untouched(self):
+ # Without a thread id there is nothing to pair against — export as-is.
+ proc = _processor()
+ proc.on_end(_make_span("user_speaking", trace_id=0xABC, span_id=0x2))
+ exported = self._exported(proc, "user_speaking")
+ assert len(exported) == 1
+ assert exported[0]._attributes["langsmith.span.kind"] == "chain"
+ assert "lk.user_transcript" not in exported[0]._attributes
+
+ def test_empty_transcript_consumes_slot_without_fake_io(self):
+ # A final-but-empty transcript still pairs (keeping FIFO aligned) but
+ # renders no fabricated I/O.
+ proc = _processor()
+ proc.on_end(self._speaking())
+ proc._record_user_transcript("call-1", "")
+
+ exported = self._exported(proc, "user_speaking")
+ assert len(exported) == 1
+ assert "gen_ai.completion" not in exported[0]._attributes
+ assert "lk.user_transcript" not in exported[0]._attributes
+
+ def test_session_end_flushes_untranscribed_span(self):
+ # Realtime input transcription disabled: no transcript ever arrives, so
+ # the held span must still be exported (untouched) at session end.
+ proc = _processor()
+ tid = 0xABC
+ # Production flow: set_thread_id + on_start caches the trace's thread
id,
+ # so the session-end flush can resolve which held spans to release.
+ set_thread_id("call-1")
+ proc.on_start(_make_span("job_entrypoint", parent=None, trace_id=tid))
+ set_thread_id(None)
+ proc.on_end(_make_span("job_entrypoint", parent=None, trace_id=tid))
+ proc.on_end(self._speaking(trace_id=tid))
+ assert self._exported(proc, "user_speaking") == [] # held
+
+ proc.on_end(_make_span("agent_session", trace_id=tid, span_id=0x3))
+
+ assert len(self._exported(proc, "user_speaking")) == 1
+ assert len(proc._deferred_user_speaking) == 0
+
+ def test_shutdown_flushes_untranscribed_span(self):
+ proc = _processor()
+ proc.on_end(self._speaking())
+ proc.shutdown()
+ assert len(self._exported(proc, "user_speaking")) == 1
+
+
+class TestRealtimeRootRollup:
+ """A late realtime user transcript sorts into its place in the root rollup.
+
+ The transcript arrives after its turn's ``agent_turn`` reply is already
+ recorded, so ordering keys off each message's source-span start_time — the
+ ``user_speaking`` span (earlier) before the ``agent_turn`` reply (later).
+ """
+
+ def _root(self, proc):
+ return next(
+ c.args[0]
+ for c in proc.downstream.on_end.call_args_list
+ if c.args[0]._attributes.get("langsmith.root_span")
+ )
+
+ def test_user_sorts_before_reply_despite_late_arrival(self):
+ proc = _processor()
+ tid = 0xABC
+ proc.on_end(
+ _make_span(
+ "job_entrypoint",
+ {"langsmith.metadata.thread_id": "call-1"},
+ parent=None,
+ trace_id=tid,
+ )
+ )
+ # User speaks (early span), then the reply turn lands and is
recorded...
+ proc.on_end(
+ _make_span(
+ "user_speaking",
+ {"langsmith.metadata.thread_id": "call-1"},
+ trace_id=tid,
+ span_id=0x2,
+ start_time=10,
+ )
+ )
+ proc.on_end(
+ _make_span(
+ "agent_turn",
+ {"lk.response.text": "sunny"},
+ trace_id=tid,
+ span_id=0x3,
+ start_time=20,
+ )
+ )
+ # ...only *then* does the transcript arrive (out of order).
+ proc._record_user_transcript("call-1", "weather?")
+ proc.on_end(_make_span("agent_session", trace_id=tid, span_id=0x4))
+
+ root = self._root(proc)
+ prompt = json.loads(root._attributes["gen_ai.prompt"])["messages"]
+ assert [m["content"] for m in prompt] == ["weather?", "sunny"]
+ assert "gen_ai.completion" not in root._attributes
+
+ def test_greeting_then_user_turn_ordered(self):
+ # Agent greets first (agent_turn, no user_speaking), then a user turn.
+ # The greeting must not steal the user's transcript, and order holds.
+ proc = _processor()
+ tid = 0xABC
+ proc.on_end(
+ _make_span(
+ "job_entrypoint",
+ {"langsmith.metadata.thread_id": "call-1"},
+ parent=None,
+ trace_id=tid,
+ )
+ )
+ proc.on_end(
+ _make_span(
+ "agent_turn",
+ {"lk.response.text": "hi there!"},
+ trace_id=tid,
+ span_id=0x2,
+ start_time=5,
+ )
+ )
+ proc.on_end(
+ _make_span(
+ "user_speaking",
+ {"langsmith.metadata.thread_id": "call-1"},
+ trace_id=tid,
+ span_id=0x3,
+ start_time=10,
+ )
+ )
+ proc.on_end(
+ _make_span(
+ "agent_turn",
+ {"lk.response.text": "sunny"},
+ trace_id=tid,
+ span_id=0x4,
+ start_time=20,
+ )
+ )
+ proc._record_user_transcript("call-1", "weather?")
+ proc.on_end(_make_span("agent_session", trace_id=tid, span_id=0x5))
+
+ root = self._root(proc)
+ contents = [
+ m["content"]
+ for m in json.loads(root._attributes["gen_ai.prompt"])["messages"]
+ ]
+ assert contents == ["hi there!", "weather?", "sunny"]
+
+
+class TestInstrumentSession:
+ """instrument_session subscribes the processor to a session's events
itself."""
+
+ class _FakeSession:
+ """Minimal stand-in for a LiveKit AgentSession's event emitter."""
+
+ def __init__(self):
+ self.handlers = {}
+
+ def on(self, name):
+ def _register(fn):
+ self.handlers[name] = fn
+ return fn
+
+ return _register
+
+ @staticmethod
+ def _event(*, is_final, transcript):
+ ev = MagicMock()
+ ev.is_final = is_final
+ ev.transcript = transcript
+ return ev
+
+ def test_final_transcript_wired_to_processor(self):
+ proc = _processor()
+ session = self._FakeSession()
+ proc.instrument_session(session, "call-1")
+
+ proc.on_end(
+ _make_span(
+ "user_speaking",
+ {"langsmith.metadata.thread_id": "call-1"},
+ span_id=0x2,
+ )
+ )
+ session.handlers["user_input_transcribed"](
+ self._event(is_final=True, transcript="hello there")
+ )
+
+ exported = [
+ c.args[0]
+ for c in proc.downstream.on_end.call_args_list
+ if c.args[0].name == "user_speaking"
+ ]
+ assert len(exported) == 1
+ assert exported[0]._attributes["lk.user_transcript"] == "hello there"
+
+ def test_interim_transcript_ignored(self):
+ proc = _processor()
+ session = self._FakeSession()
+ proc.instrument_session(session, "call-1")
+
+ session.handlers["user_input_transcribed"](
+ self._event(is_final=False, transcript="partial")
+ )
+ # Interim result buffered nothing; a later span has no transcript to
pair.
+ assert len(proc._pending_user_transcripts) == 0
+
+
class TestForceFlush:
"""force_flush must not finalize conversations still in progress."""
@@ -259,7 +553,8 @@
"agent_turn", {"lk.response.text": "two"}, trace_id=tid,
span_id=0x3
)
)
- assert [m["content"] for m in proc._transcript_by_trace[tid]] == [
+ # The store holds (sort_key, message) tuples.
+ assert [m["content"] for _, m in proc._transcript_by_trace[tid]] == [
"one",
"two",
]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/langsmith-0.10.3/tests/unit_tests/wrappers/test_openai_realtime_agents.py
new/langsmith-0.10.5/tests/unit_tests/wrappers/test_openai_realtime_agents.py
---
old/langsmith-0.10.3/tests/unit_tests/wrappers/test_openai_realtime_agents.py
2020-02-02 01:00:00.000000000 +0100
+++
new/langsmith-0.10.5/tests/unit_tests/wrappers/test_openai_realtime_agents.py
2020-02-02 01:00:00.000000000 +0100
@@ -25,6 +25,7 @@
_stringify,
describe_event,
raw_input_transcript,
+ raw_response_usage,
wrap_realtime_session,
)
@@ -130,6 +131,35 @@
is None
)
+ def test_raw_response_usage(self):
+ ev = NS(
+ type="raw_model_event",
+ data=NS(
+ type="raw_server_event",
+ data={
+ "type": "response.done",
+ "response": {"usage": {"input_tokens": 7, "output_tokens":
3}},
+ },
+ ),
+ )
+ usage = raw_response_usage(ev)
+ assert usage["input_tokens"] == 7
+ assert usage["output_tokens"] == 3
+ # Non-usage raw events and unrelated events return None.
+ assert raw_response_usage(NS(data=NS(type="raw_server_event",
data={}))) is None
+ assert raw_response_usage(NS(data=NS(type="other"))) is None
+ assert (
+ raw_response_usage(
+ NS(
+ data=NS(
+ type="raw_server_event",
+ data={"type": "response.done", "response": {}},
+ )
+ )
+ )
+ is None
+ )
+
def test_describe_event_variants(self):
name, payload, inbound = describe_event(
NS(type="tool_start", tool=NS(name="lookup"), arguments='{"q": 1}')
@@ -332,6 +362,90 @@
assert session.messages == [{"role": "user", "content": "cancel that"}]
assert any(n == "user_message" for n, _ in created)
+ def test_response_done_usage_attached_to_assistant_span(self, monkeypatch):
+ # The SDK has no semantic usage event, but forwards the raw
response.done
+ # (as raw_server_event); its usage must ride on the assistant model
span,
+ # with audio/cached detail preserved for correct pricing.
+ session = start_session(
+ thread_id="t", sample_rate=24_000,
integration="openai-agents-realtime"
+ )
+ created = _spy_children(monkeypatch)
+ tracer = _tracer(session)
+
+ tracer.observe(_history_updated(_item("u1", "user", "weather?")))
+ tracer.observe(
+ NS(
+ type="raw_model_event",
+ data=NS(
+ type="raw_server_event",
+ data={
+ "type": "response.done",
+ "response": {
+ "usage": {
+ "input_tokens": 300,
+ "output_tokens": 120,
+ "total_tokens": 420,
+ "input_token_details": {
+ "audio_tokens": 250,
+ "cached_tokens": 20,
+ "text_tokens": 30,
+ },
+ "output_token_details": {
+ "audio_tokens": 100,
+ "text_tokens": 20,
+ },
+ }
+ },
+ },
+ ),
+ )
+ )
+ tracer.observe(
+ _history_updated(
+ _item("u1", "user", "weather?"),
+ _item("a1", "assistant", "It's sunny."),
+ )
+ )
+ tracer.flush_pending()
+ session.finalize()
+
+ model = next(c for n, c in created if n == "model")
+ usage = (model.extra or {}).get("metadata", {}).get("usage_metadata")
+ assert usage["input_tokens"] == 300
+ assert usage["output_tokens"] == 120
+ assert usage["input_token_details"]["audio"] == 250
+ assert usage["input_token_details"]["cache_read"] == 20
+ assert usage["output_token_details"]["audio"] == 100
+
+ def test_response_done_usage_consumed_once(self, monkeypatch):
+ # Usage is cleared after it's attached, so a later assistant turn
without
+ # its own response.done doesn't inherit the previous turn's counts.
+ session = start_session(
+ thread_id="t", sample_rate=24_000,
integration="openai-agents-realtime"
+ )
+ created = _spy_children(monkeypatch)
+ tracer = _tracer(session)
+
+ tracer.observe(
+ NS(
+ type="raw_model_event",
+ data=NS(
+ type="raw_server_event",
+ data={
+ "type": "response.done",
+ "response": {"usage": {"input_tokens": 10,
"output_tokens": 5}},
+ },
+ ),
+ )
+ )
+ tracer.observe(_history_updated(_item("a1", "assistant", "one")))
+ tracer.observe(_history_updated(_item("a2", "assistant", "two")))
+ tracer.flush_pending()
+
+ models = [c for n, c in created if n == "model"]
+ assert (models[0].extra or {}).get("metadata",
{}).get("usage_metadata")
+ assert (models[1].extra or {}).get("metadata",
{}).get("usage_metadata") is None
+
def test_on_message_called_once_per_finalized_line(self):
session = start_session(
thread_id="t", sample_rate=24_000,
integration="openai-agents-realtime"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/langsmith-0.10.3/tests/unit_tests/wrappers/test_voice_session.py
new/langsmith-0.10.5/tests/unit_tests/wrappers/test_voice_session.py
--- old/langsmith-0.10.3/tests/unit_tests/wrappers/test_voice_session.py
2020-02-02 01:00:00.000000000 +0100
+++ new/langsmith-0.10.5/tests/unit_tests/wrappers/test_voice_session.py
2020-02-02 01:00:00.000000000 +0100
@@ -223,6 +223,37 @@
assert len(captured) == 1
assert captured[0].inputs == {"messages": [{"role": "user", "content":
"hi"}]}
+ def test_usage_lands_on_the_run_before_it_is_finalized(self):
+ # Cost is derived when the run is finalized, so usage must be set on
the
+ # run's extra.metadata before end/patch — never on a later patch.
+ s = _session()
+ captured: list = []
+ real_create = session_mod.RunTree.create_child
+
+ def spy(self, **kwargs):
+ child = real_create(self, **kwargs)
+ if kwargs.get("run_type") == "llm":
+ captured.append(child)
+ return child
+
+ with mock.patch.object(session_mod.RunTree, "create_child", spy):
+ s.record_llm(
+ outputs={"role": "assistant", "content": "hi"},
+ usage_metadata={
+ "input_tokens": 3,
+ "output_tokens": 2,
+ "total_tokens": 5,
+ },
+ )
+ assert len(captured) == 1
+ run = captured[0]
+ assert run.end_time is not None # finalized
+ assert run.extra["metadata"]["usage_metadata"] == {
+ "input_tokens": 3,
+ "output_tokens": 2,
+ "total_tokens": 5,
+ }
+
class TestFinalizeFailOpen:
def test_wav_build_failure_does_not_break_finalize(self, monkeypatch):
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/langsmith-0.10.3/uv.lock new/langsmith-0.10.5/uv.lock
--- old/langsmith-0.10.3/uv.lock 2020-02-02 01:00:00.000000000 +0100
+++ new/langsmith-0.10.5/uv.lock 2020-02-02 01:00:00.000000000 +0100
@@ -1784,7 +1784,7 @@
{ name = "claude-agent-sdk", marker = "python_full_version >= '3.10' and
extra == 'claude-agent-sdk'", specifier = ">=0.1.0" },
{ name = "distro", specifier = ">=1.7.0" },
{ name = "google-adk", marker = "extra == 'google-adk'", specifier =
">=1.0.0" },
- { name = "google-adk", marker = "extra == 'google-adk-live'", specifier =
">=1.14.0" },
+ { name = "google-adk", marker = "extra == 'google-adk-live'", specifier =
">=2.3.0" },
{ name = "httpx", specifier = ">=0.23.0,<1" },
{ name = "langsmith-pyo3", marker = "extra == 'langsmith-pyo3'", specifier
= ">=0.1.0rc2" },
{ name = "livekit-agents", marker = "extra == 'livekit'", specifier =
">=1.0" },