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-18 22:24:48
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-langsmith (Old)
and /work/SRC/openSUSE:Factory/.python-langsmith.new.24530 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-langsmith"
Sat Jul 18 22:24:48 2026 rev:11 rq:1366477 version:0.10.6
Changes:
--------
--- /work/SRC/openSUSE:Factory/python-langsmith/python-langsmith.changes
2026-07-17 01:44:05.782508310 +0200
+++
/work/SRC/openSUSE:Factory/.python-langsmith.new.24530/python-langsmith.changes
2026-07-18 22:26:02.152198621 +0200
@@ -1,0 +2,9 @@
+Sat Jul 18 05:55:29 UTC 2026 - Martin Pluskal <[email protected]>
+
+- Update to 0.10.6:
+ * Spread OpenAI request metadata into the traced metadata
+ * Capture Pipecat realtime tool calls, transcripts and OpenAI
+ realtime history
+ * Dependency and packaging fixes
+
+-------------------------------------------------------------------
Old:
----
langsmith-0.10.5.tar.gz
New:
----
langsmith-0.10.6.tar.gz
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Other differences:
------------------
++++++ python-langsmith.spec ++++++
--- /var/tmp/diff_new_pack.HBUipe/_old 2026-07-18 22:26:02.740218480 +0200
+++ /var/tmp/diff_new_pack.HBUipe/_new 2026-07-18 22:26:02.740218480 +0200
@@ -17,7 +17,7 @@
Name: python-langsmith
-Version: 0.10.5
+Version: 0.10.6
Release: 0
Summary: Client library for the LangSmith LLM tracing and evaluation
platform
License: MIT
++++++ langsmith-0.10.5.tar.gz -> langsmith-0.10.6.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/langsmith-0.10.5/.bumpversion.cfg
new/langsmith-0.10.6/.bumpversion.cfg
--- old/langsmith-0.10.5/.bumpversion.cfg 2020-02-02 01:00:00.000000000
+0100
+++ new/langsmith-0.10.6/.bumpversion.cfg 2020-02-02 01:00:00.000000000
+0100
@@ -1,5 +1,5 @@
[bumpversion]
-current_version = 0.10.5
+current_version = 0.10.6
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.5/PKG-INFO
new/langsmith-0.10.6/PKG-INFO
--- old/langsmith-0.10.5/PKG-INFO 2020-02-02 01:00:00.000000000 +0100
+++ new/langsmith-0.10.6/PKG-INFO 2020-02-02 01:00:00.000000000 +0100
@@ -1,6 +1,6 @@
Metadata-Version: 2.4
Name: langsmith
-Version: 0.10.5
+Version: 0.10.6
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/
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/langsmith-0.10.5/langsmith/__init__.py
new/langsmith-0.10.6/langsmith/__init__.py
--- old/langsmith-0.10.5/langsmith/__init__.py 2020-02-02 01:00:00.000000000
+0100
+++ new/langsmith-0.10.6/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.5"
+__version__ = "0.10.6"
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.5/langsmith/_internal/voice/translated_span.py
new/langsmith-0.10.6/langsmith/_internal/voice/translated_span.py
--- old/langsmith-0.10.5/langsmith/_internal/voice/translated_span.py
2020-02-02 01:00:00.000000000 +0100
+++ new/langsmith-0.10.6/langsmith/_internal/voice/translated_span.py
2020-02-02 01:00:00.000000000 +0100
@@ -10,6 +10,7 @@
from __future__ import annotations
import json
+from copy import copy
from dataclasses import dataclass
from typing import Any, Optional
@@ -29,11 +30,10 @@
class TranslatedSpan:
"""A span being translated into LangSmith's namespaces before export.
- Wraps the original read-only OTel ``ReadableSpan`` with mutable
- ``attributes`` and ``events`` seeded from it. Handlers rewrite the copies
- while translating; :meth:`finalize` builds a fresh ``ReadableSpan`` from
them
- — so OpenTelemetry's private ``span._attributes`` / ``span._events`` are
- never mutated.
+ Wraps a shallow copy of the original read-only OTel ``ReadableSpan`` with
+ mutable ``attributes`` and ``events`` seeded from it. Handlers rewrite the
+ draft while translating; :meth:`finalize` builds a fresh ``ReadableSpan``
+ from it — so the original span is never mutated.
Created per span in :meth:`BaseLangSmithSpanProcessor.on_end` and threaded
through dispatch — no global state. A processor that defers a span (see
@@ -48,15 +48,31 @@
@classmethod
def of(cls, span: ReadableSpan) -> TranslatedSpan:
- """Seed a draft from a span's own (read-only) attributes and events."""
- return cls(span, dict(span.attributes or {}), list(span.events or []))
+ """Seed a draft from a span's own fields, attributes, and events."""
+ return cls(
+ copy(span),
+ dict(span.attributes or {}),
+ list(span.events or []),
+ )
def finalize(self) -> ReadableSpan:
- """Build the export span: the original's fields + our rewritten
attrs/events."""
+ """Build the export span from the draft and its rewritten
attrs/events."""
return rebuild_readable_span(
self.span, attributes=self.attributes, events=self.events
)
+ def set_name(self, name: str) -> None:
+ """Set the exported span's name (the LangSmith run name)."""
+ self.span._name = name
+
+ def set_end_time(self, end_time_ns: int) -> None:
+ """Set the exported span's end time (epoch ns).
+
+ Lets a span merged from two sources report a duration that runs past
its
+ own end — e.g. a tool span spanning from the call to the result.
+ """
+ self.span._end_time = end_time_ns
+
def set_kind(self, kind: str) -> None:
"""Set ``langsmith.span.kind`` (``llm`` / ``chain`` / ``tool`` / …)."""
self.attributes["langsmith.span.kind"] = kind
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/langsmith-0.10.5/langsmith/integrations/pipecat/__init__.py
new/langsmith-0.10.6/langsmith/integrations/pipecat/__init__.py
--- old/langsmith-0.10.5/langsmith/integrations/pipecat/__init__.py
2020-02-02 01:00:00.000000000 +0100
+++ new/langsmith-0.10.6/langsmith/integrations/pipecat/__init__.py
2020-02-02 01:00:00.000000000 +0100
@@ -47,6 +47,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) service, also call
+ :meth:`PipecatLangSmithSpanProcessor.instrument_user_aggregator` on the
+ returned processor. Pipecat delivers finalized user text through the user
+ context aggregator rather than an OTel span.
+
Args:
llm_span_kind: LangSmith run kind for Pipecat's ``llm`` span; see the
:mod:`~langsmith.integrations.pipecat.processor` module docstring.
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/langsmith-0.10.5/langsmith/integrations/pipecat/_helpers.py
new/langsmith-0.10.6/langsmith/integrations/pipecat/_helpers.py
--- old/langsmith-0.10.5/langsmith/integrations/pipecat/_helpers.py
2020-02-02 01:00:00.000000000 +0100
+++ new/langsmith-0.10.6/langsmith/integrations/pipecat/_helpers.py
2020-02-02 01:00:00.000000000 +0100
@@ -3,7 +3,8 @@
from __future__ import annotations
import json
-from typing import Any
+from datetime import datetime
+from typing import Any, Optional
from langsmith._internal.voice._helpers import (
build_assistant_message,
@@ -61,3 +62,26 @@
return {"role": "assistant", **parsed}
content = output_data if isinstance(output_data, str) else str(output_data)
return build_assistant_message(content)
+
+
+def iso_to_ns(timestamp: str) -> int:
+ """Parse an ISO 8601 timestamp to epoch nanoseconds (0 if unparseable)."""
+ try:
+ return int(datetime.fromisoformat(timestamp).timestamp() * 1e9)
+ except (ValueError, TypeError):
+ return 0
+
+
+def tool_message_key(message: dict[str, Any]) -> Optional[tuple]:
+ """Dedup identity for a tool-round-trip message, or ``None`` otherwise.
+
+ Only assistant tool-call messages and tool-result messages carry one;
+ user turns and plain assistant replies (owned by other sources) return
+ ``None`` so they are ignored here.
+ """
+ tool_calls = message.get("tool_calls")
+ if tool_calls:
+ return ("assistant_tool_call", tuple(str(c.get("id")) for c in
tool_calls))
+ if message.get("role") == "tool":
+ return ("tool_result", str(message.get("tool_call_id")))
+ return None
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/langsmith-0.10.5/langsmith/integrations/pipecat/processor.py
new/langsmith-0.10.6/langsmith/integrations/pipecat/processor.py
--- old/langsmith-0.10.5/langsmith/integrations/pipecat/processor.py
2020-02-02 01:00:00.000000000 +0100
+++ new/langsmith-0.10.6/langsmith/integrations/pipecat/processor.py
2020-02-02 01:00:00.000000000 +0100
@@ -13,6 +13,7 @@
└── turn × N
├── stt (audio → transcript)
├── llm (the LLM stage; kind set by llm_span_kind)
+ ├── tool × N (realtime: one per tool call, args → result)
└── tts (response text → audio)
Audio uses Pipecat's ``AudioBufferProcessor``: wire it with
@@ -26,6 +27,11 @@
Speech-to-speech (realtime) services emit operation-named spans:
``llm_response``
(→ ``llm`` run, usage + reply) and the ``llm_setup`` / ``llm_request``
wrappers.
+Call :meth:`PipecatLangSmithSpanProcessor.instrument_user_aggregator` to supply
+finalized user transcripts emitted outside those spans. A realtime tool call
+arrives as two sibling spans, ``llm_tool_call`` (args) then
+``llm_tool_result`` (output); the processor defers the call and merges the
result
+onto it, so each call renders as one ``tool`` run spanning call → result.
"""
from __future__ import annotations
@@ -51,10 +57,24 @@
from langsmith.integrations.pipecat._helpers import (
build_completion_message,
extract_llm_usage,
+ iso_to_ns,
parse_llm_messages,
+ tool_message_key,
)
+# Sort key for transcript entries: (epoch nanoseconds, sequence). Ordering the
+# rollup by when each message actually occurred — rather than by the order
spans
+# arrive — keeps realtime user turns (whose transcripts land late, after the
+# model has already replied) in their spoken position. ``seq`` breaks ties
within
+# one source (e.g. tool call before its result from the same snapshot).
+_SortKey = tuple[int, int]
+
if TYPE_CHECKING:
+ from pipecat.processors.aggregators.llm_response_universal import ( #
type: ignore[import-not-found]
+ LLMContextAggregatorPair,
+ LLMUserAggregator,
+ UserTurnMessageAddedMessage,
+ )
from pipecat.processors.audio.audio_buffer_processor import ( # type:
ignore[import-not-found]
AudioBufferProcessor,
)
@@ -135,15 +155,97 @@
)
self._llm_span_kind = llm_span_kind
self._audio_mime_type = audio_mime_type
- # trace_id -> latest llm context (the full history == the
conversation),
- # rendered onto the root ``conversation`` span, which ends last.
- self._transcript_by_trace: MutableMapping[int, list[dict[str, Any]]] =
TTLCache(
- maxsize=DEFAULT_STATE_MAXSIZE, ttl=state_ttl_seconds
- )
+ # trace_id -> the conversation the root renders, as ``(sort_key,
message)``
+ # entries ordered by when each message occurred (see ``_SortKey``).
+ self._transcript_by_trace: MutableMapping[
+ int, list[tuple[_SortKey, dict[str, Any]]]
+ ] = TTLCache(maxsize=DEFAULT_STATE_MAXSIZE, ttl=state_ttl_seconds)
# conversation id -> merged PCM from an AudioBufferProcessor.
self._audio_by_conversation: MutableMapping[str, _AudioRecord] =
TTLCache(
maxsize=DEFAULT_STATE_MAXSIZE, ttl=state_ttl_seconds
)
+ # trace_id -> FIFO of deferred ``llm_tool_call`` spans, each held until
+ # its ``llm_tool_result`` arrives so the two merge into one tool span.
+ self._tool_calls_by_trace: MutableMapping[int, list[TranslatedSpan]] =
TTLCache(
+ maxsize=DEFAULT_STATE_MAXSIZE, ttl=state_ttl_seconds
+ )
+ # thread id -> trace_id, so realtime user transcripts delivered outside
+ # spans can find the conversation rollup they belong to.
+ self._trace_by_thread: MutableMapping[str, int] = TTLCache(
+ maxsize=DEFAULT_STATE_MAXSIZE, ttl=state_ttl_seconds
+ )
+ # thread id -> user turns (as ``(sort_key, message)``) that arrived
before
+ # their trace was known.
+ self._pending_user_transcripts: MutableMapping[
+ str, list[tuple[_SortKey, dict[str, Any]]]
+ ] = TTLCache(maxsize=DEFAULT_STATE_MAXSIZE, ttl=state_ttl_seconds)
+
+ def _remember_thread_id(self, trace_id: int, thread_id: str) -> None:
+ """Also index thread→trace, draining user turns buffered before the
map."""
+ super()._remember_thread_id(trace_id, thread_id)
+ self._trace_by_thread[thread_id] = trace_id
+ pending = self._pending_user_transcripts.pop(thread_id, None)
+ for sort_key, message in pending or []:
+ self._append_transcript(trace_id, message, sort_key)
+
+ # -- realtime user transcript --------------------------------------------
+
+ def instrument_user_aggregator(
+ self, aggregator: LLMContextAggregatorPair, thread_id: str
+ ) -> None:
+ """Fold a realtime session's finalized user transcripts into its trace.
+
+ Pipecat realtime services emit the user's finalized text through the
user
+ context aggregator's ``on_user_turn_message_added`` event rather than
on
+ an OTel span. Call this once after building the context aggregators::
+
+ processor = configure_pipecat(...)
+ aggregators = LLMContextAggregatorPair(context)
+ set_thread_id(conversation_id)
+ processor.instrument_user_aggregator(aggregators, conversation_id)
+
+ The aggregator is the authoritative source of user turns: a realtime
+ service transcribes the user's audio asynchronously, so the text lands
+ (via this event, carrying the turn-start timestamp) after the model has
+ already replied. The transcript is ordered by that timestamp, not
arrival.
+
+ Args:
+ aggregator: the ``LLMContextAggregatorPair`` for the conversation.
+ thread_id: the conversation id, matching :func:`set_thread_id`.
+ """
+ user_aggregator = aggregator.user()
+ tid = str(thread_id)
+
+ @user_aggregator.event_handler("on_user_turn_message_added")
+ async def _on_user_turn_message_added(
+ _aggregator: LLMUserAggregator, message:
UserTurnMessageAddedMessage
+ ) -> None:
+ self._record_user_transcript(tid, message.content,
message.timestamp)
+
+ def _record_user_transcript(
+ self, thread_id: str, text: str, timestamp: str
+ ) -> None:
+ """Append or buffer a finalized user turn, keyed by when it was
spoken."""
+ if not text:
+ return
+ message = build_user_message(text)
+ sort_key = (iso_to_ns(timestamp), 0)
+ trace_id = self._trace_by_thread.get(thread_id)
+ if trace_id is None:
+ pending = self._pending_user_transcripts.get(thread_id) or []
+ pending.append((sort_key, message))
+ self._pending_user_transcripts[thread_id] = pending
+ return
+ self._append_transcript(trace_id, message, sort_key)
+
+ def _append_transcript(
+ self, trace_id: int, message: dict[str, Any], sort_key: _SortKey
+ ) -> None:
+ """Add a message to the transcript the root renders, 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.
+ self._transcript_by_trace[trace_id] = conversation
# -- audio buffer registration -------------------------------------------
@@ -215,8 +317,14 @@
self._handle_conversation(tspan, trace_id)
elif name == "llm_response": # realtime: usage + reply
self._handle_realtime_response(tspan, trace_id)
- elif name in ("llm_setup", "llm_request"):
- tspan.set_kind("chain") # realtime wrappers
+ elif name == "llm_request": # OpenAI realtime: history snapshot
+ self._handle_llm_request(tspan, trace_id)
+ elif name == "llm_setup":
+ tspan.set_kind("chain") # realtime wrapper
+ elif name == "llm_tool_call": # realtime: defer, merge with its result
+ return self._handle_tool_call(tspan, trace_id)
+ elif name == "llm_tool_result":
+ return self._handle_tool_result(tspan, trace_id)
return True
# -- per-span-type handlers ----------------------------------------------
@@ -224,15 +332,20 @@
def _handle_stt(self, tspan: TranslatedSpan, trace_id: int) -> None:
"""STT span: audio input → transcribed text."""
transcript = tspan.attributes.get("transcript", "")
+ is_final = tspan.attributes.get("is_final", True)
tspan.set_kind("llm")
tspan.set_messages(prompt=[build_user_message(f'Audio for:
"{transcript}"')])
if transcript:
tspan.set_messages(completion=[build_assistant_message(str(transcript))])
- # Fold the user turn into the rollup (realtime has no cascade llm
- # span to carry it; cascade's llm span replaces it with full
history).
- if tspan.attributes.get("is_final", True):
- self._transcript_by_trace.setdefault(trace_id, []).append(
- build_user_message(str(transcript))
+ # Fold the finalized user turn into the rollup, keyed by when it
was
+ # transcribed. Realtime services emit only interim (is_final=False)
+ # stt spans, so their user turns come from the aggregator instead;
+ # this fold is the cascade path (STT→LLM→TTS, no aggregator).
+ if is_final:
+ self._append_transcript(
+ trace_id,
+ build_user_message(str(transcript)),
+ (tspan.span.start_time or 0, 0),
)
tspan.exclude_from_message_view()
@@ -256,11 +369,17 @@
if usage:
tspan.set_usage(**usage)
+ # Cascade: the ``llm`` input already carries the whole ordered
history, so
+ # replace the transcript with this snapshot, keyed so the messages keep
+ # their order and sort after earlier turns.
transcript = [m for m in messages if m.get("role") != "system"]
if output_data:
transcript.append(build_completion_message(output_data))
if transcript:
- self._transcript_by_trace[trace_id] = transcript
+ base = tspan.span.start_time or 0
+ self._transcript_by_trace[trace_id] = [
+ ((base, i), message) for i, message in enumerate(transcript)
+ ]
def _handle_tts(self, tspan: TranslatedSpan) -> None:
"""TTS span: text → audio. The voice is metadata, not content."""
@@ -276,20 +395,105 @@
tspan.exclude_from_message_view()
def _handle_realtime_response(self, tspan: TranslatedSpan, trace_id: int)
-> None:
- """``llm_response`` span from a realtime (speech-to-speech) service."""
+ """``llm_response`` span: conversation so far → realtime reply."""
tspan.set_kind(self._llm_span_kind)
+ conversation = self._render_messages(trace_id)
+ if conversation:
+ tspan.set_messages(prompt=conversation)
+
output_data = tspan.attributes.get("output") or tspan.attributes.get(
"text_output", ""
)
if output_data:
completion = build_completion_message(output_data)
tspan.set_messages(completion=[completion])
- self._transcript_by_trace.setdefault(trace_id,
[]).append(completion)
+ self._append_transcript(
+ trace_id, completion, (tspan.span.start_time or 0, 0)
+ )
usage = extract_llm_usage(tspan.attributes)
if usage:
tspan.set_usage(**usage)
+ def _handle_llm_request(self, tspan: TranslatedSpan, trace_id: int) ->
None:
+ """``llm_request`` span (OpenAI realtime): capture the tool round-trip.
+
+ For OpenAI realtime, tool calls and results have no spans of their own
—
+ they exist only in this context snapshot. User turns come from the
+ aggregator and assistant replies from ``llm_response``, so take *only*
the
+ tool-call / tool-result messages here, deduped by their id, keyed by
this
+ span's time so they sort after the user turn that triggered them.
+ """
+ tspan.set_kind("chain")
+ messages = parse_llm_messages(tspan.attributes.get("input", ""))
+ seen = {
+ key
+ for message in self._render_messages(trace_id)
+ if (key := tool_message_key(message)) is not None
+ }
+ base = tspan.span.start_time or 0
+ added = 0
+ for message in messages:
+ key = tool_message_key(message)
+ if key is None or key in seen:
+ continue
+ seen.add(key)
+ self._append_transcript(trace_id, message, (base, added))
+ added += 1
+
+ def _render_messages(self, trace_id: int) -> list[dict[str, Any]]:
+ """Return the transcript as plain messages, ordered by sort key."""
+ entries = self._transcript_by_trace.get(trace_id, [])
+ return [message for _, message in sorted(entries, key=lambda e: e[0])]
+
+ def _handle_tool_call(self, tspan: TranslatedSpan, trace_id: int) -> bool:
+ """``llm_tool_call`` span: defer the call (args) until its result
arrives."""
+ tspan.set_kind("tool")
+ function_name = tspan.attributes.get("tool.function_name")
+ if function_name:
+ tspan.set_name(str(function_name))
+ arguments = tspan.attributes.get("tool.arguments")
+ if arguments is not None:
+ tspan.set_tool_input(arguments)
+ self._tool_calls_by_trace.setdefault(trace_id, []).append(tspan)
+ return False # deferred; exported when its result arrives
+
+ def _handle_tool_result(self, tspan: TranslatedSpan, trace_id: int) ->
bool:
+ """``llm_tool_result`` span: merge the result onto its deferred call
span.
+
+ Pairs with the oldest deferred call for the trace (Gemini Live puts no
+ usable id on the result span, so pairing is by order). The result
becomes
+ the tool run's output and stretches the span to end at the result, so
it
+ spans call → result.
+ """
+ result = tspan.attributes.get("tool.result")
+ call_tspan = self._take_pending_call(trace_id)
+ if call_tspan is None:
+ tspan.set_kind("tool")
+ if result is not None:
+ tspan.set_tool_output(result)
+ return True
+
+ if result is not None:
+ call_tspan.set_tool_output(result)
+ status = tspan.attributes.get("tool.result_status")
+ if status is not None:
+ call_tspan.set_metadata("tool_result_status", str(status))
+ if tspan.span.end_time is not None:
+ call_tspan.set_end_time(tspan.span.end_time)
+ self._export(call_tspan)
+ return False # merged into the call span; drop this result span
+
+ def _take_pending_call(self, trace_id: int) -> Optional[TranslatedSpan]:
+ """Pop the oldest deferred call span for the trace, or ``None``."""
+ queue = self._tool_calls_by_trace.get(trace_id)
+ if not queue:
+ return None
+ call_tspan = queue.pop(0)
+ if not queue:
+ self._tool_calls_by_trace.pop(trace_id, None)
+ return call_tspan
+
def _handle_turn(self, tspan: TranslatedSpan) -> None:
"""Turn span: a framework wrapper around one exchange (a ``chain``)."""
tspan.set_kind("chain")
@@ -313,7 +517,7 @@
"ls_integration_version", get_package_version("pipecat-ai") or ""
)
- messages = self._transcript_by_trace.get(trace_id, [])
+ messages = self._render_messages(trace_id)
if messages:
tspan.set_messages(prompt=messages)
@@ -359,6 +563,28 @@
)
def _cleanup_conversation(self, trace_id: int, conversation_id: str) ->
None:
+ thread_id = self._thread_id_by_trace.get(trace_id)
self._transcript_by_trace.pop(trace_id, None)
self._audio_by_conversation.pop(conversation_id, None)
+ if thread_id is not None:
+ self._trace_by_thread.pop(thread_id, None)
+ self._pending_user_transcripts.pop(thread_id, None)
self._forget_thread_id(trace_id)
+ self._flush_tool_calls(trace_id)
+
+ def _flush_tool_calls(self, trace_id: Optional[int] = None) -> None:
+ """Export held tool-call spans whose result never arrived (args only).
+
+ Scoped to one trace at conversation end, or all held spans at shutdown.
+ """
+ trace_ids = (
+ [trace_id] if trace_id is not None else
list(self._tool_calls_by_trace)
+ )
+ for tid in trace_ids:
+ for tspan in self._tool_calls_by_trace.pop(tid, None) or []:
+ self._export(tspan)
+
+ def shutdown(self) -> None:
+ """Flush any still-held tool-call spans, then shut down downstream."""
+ self._flush_tool_calls()
+ super().shutdown()
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/langsmith-0.10.5/langsmith/run_trees.py
new/langsmith-0.10.6/langsmith/run_trees.py
--- old/langsmith-0.10.5/langsmith/run_trees.py 2020-02-02 01:00:00.000000000
+0100
+++ new/langsmith-0.10.6/langsmith/run_trees.py 2020-02-02 01:00:00.000000000
+0100
@@ -1187,6 +1187,7 @@
api_url = item.get("api_url")
api_key = item.get("api_key")
+ project_name = item.get("project_name")
if not isinstance(api_url, str):
logger.warning(
@@ -1202,11 +1203,18 @@
)
continue
+ if project_name is not None and not isinstance(project_name,
str):
+ logger.warning(
+ f"Invalid project_name type in
LANGSMITH_RUNS_ENDPOINTS: "
+ f"expected string, got {type(project_name).__name__}"
+ )
+ continue
+
replicas.append(
WriteReplica(
api_url=api_url.rstrip("/"),
auth=AuthHeaders(api_key=api_key),
- project_name=None,
+ project_name=project_name,
updates=None,
)
)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/langsmith-0.10.5/langsmith/wrappers/_openai.py
new/langsmith-0.10.6/langsmith/wrappers/_openai.py
--- old/langsmith-0.10.5/langsmith/wrappers/_openai.py 2020-02-02
01:00:00.000000000 +0100
+++ new/langsmith-0.10.6/langsmith/wrappers/_openai.py 2020-02-02
01:00:00.000000000 +0100
@@ -133,7 +133,12 @@
if use_responses_api:
invocation_params["use_responses_api"] = True
+ request_metadata = stripped.get("metadata")
+ if not isinstance(request_metadata, Mapping):
+ request_metadata = {}
+
return {
+ **request_metadata,
"ls_provider": provider,
"ls_model_type": model_type,
"ls_model_name": stripped.get("model"),
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/langsmith-0.10.5/pyproject.toml
new/langsmith-0.10.6/pyproject.toml
--- old/langsmith-0.10.5/pyproject.toml 2020-02-02 01:00:00.000000000 +0100
+++ new/langsmith-0.10.6/pyproject.toml 2020-02-02 01:00:00.000000000 +0100
@@ -191,6 +191,13 @@
[tool.uv]
exclude-newer = "P1D"
+# Force `json-repair` past the patched 0.60.1 floor everywhere.
`livekit-agents`
+# pins the vulnerable `json-repair==0.59.10`; uv's `override-dependencies`
takes a
+# flat list of PEP 508 requirements (not a per-package map), so a global
override
+# is the supported way to force the patched version over that pin.
+override-dependencies = [
+ "json-repair>=0.60.1",
+]
[tool.hatch.version]
path = "langsmith/__init__.py"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/langsmith-0.10.5/tests/unit_tests/test_replica_endpoints.py
new/langsmith-0.10.6/tests/unit_tests/test_replica_endpoints.py
--- old/langsmith-0.10.5/tests/unit_tests/test_replica_endpoints.py
2020-02-02 01:00:00.000000000 +0100
+++ new/langsmith-0.10.6/tests/unit_tests/test_replica_endpoints.py
2020-02-02 01:00:00.000000000 +0100
@@ -353,8 +353,16 @@
"""Test parsing new array format."""
env_var = json.dumps(
[
- {"api_url": "https://api.example.com", "api_key": "key1"},
- {"api_url": "https://api.example.com", "api_key": "key2"},
+ {
+ "api_url": "https://api.example.com",
+ "api_key": "key1",
+ "project_name": "project-prod",
+ },
+ {
+ "api_url": "https://api.example.com",
+ "api_key": "key2",
+ "project_name": "project-staging",
+ },
{"api_url": "https://api.example.com", "api_key": "key3"},
]
)
@@ -372,8 +380,11 @@
assert "key2" in keys
assert "key3" in keys
- # All should have None for project_name and updates
- assert all(r["project_name"] is None for r in result)
+ assert [r["project_name"] for r in result] == [
+ "project-prod",
+ "project-staging",
+ None,
+ ]
assert all(r["updates"] is None for r in result)
def test_parse_object_format(self):
@@ -815,6 +826,38 @@
assert call_args[1]["api_key"] == "replica-key"
assert call_args[1]["api_url"] == "https://replica.example.com"
+ def test_run_tree_preserves_env_replica_project_names(self):
+ client = Mock()
+ env_var = json.dumps(
+ [
+ {
+ "api_url": "https://replica1.example.com",
+ "api_key": "replica1-key",
+ "project_name": "project-prod",
+ },
+ {
+ "api_url": "https://replica2.example.com",
+ "api_key": "replica2-key",
+ "project_name": "project-staging",
+ },
+ ]
+ )
+
+ with patch.dict(os.environ, {"LANGSMITH_RUNS_ENDPOINTS": env_var},
clear=True):
+ _parse_write_replicas_from_env_var.cache_clear()
+ run_tree = RunTree(
+ name="test_run",
+ inputs={"input": "test"},
+ client=client,
+ project_name="fallback-project",
+ )
+ run_tree.post()
+
+ session_names = [
+ call.kwargs["session_name"] for call in
client.create_run.call_args_list
+ ]
+ assert session_names == ["project-prod", "project-staging"]
+
class TestBaggageReplicaParsing:
"""Test baggage header parsing for replicas."""
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/langsmith-0.10.5/tests/unit_tests/wrappers/test_openai_processing.py
new/langsmith-0.10.6/tests/unit_tests/wrappers/test_openai_processing.py
--- old/langsmith-0.10.5/tests/unit_tests/wrappers/test_openai_processing.py
1970-01-01 01:00:00.000000000 +0100
+++ new/langsmith-0.10.6/tests/unit_tests/wrappers/test_openai_processing.py
2020-02-02 01:00:00.000000000 +0100
@@ -0,0 +1,58 @@
+"""Unit tests for OpenAI wrapper processing functions."""
+
+import pytest
+
+from langsmith.wrappers._openai import _infer_invocation_params
+
+
+def test_infer_invocation_params_copies_request_metadata():
+ result = _infer_invocation_params(
+ "chat",
+ "openai",
+ {},
+ False,
+ {
+ "model": "gpt-4o-mini",
+ "metadata": {
+ "customer_id": "customer-123",
+ "environment": "test",
+ },
+ },
+ )
+
+ assert result["customer_id"] == "customer-123"
+ assert result["environment"] == "test"
+ assert "metadata" not in result["ls_invocation_params"]
+
+
+def test_infer_invocation_params_protects_langsmith_metadata():
+ result = _infer_invocation_params(
+ "chat",
+ "openai",
+ {},
+ False,
+ {
+ "model": "gpt-4o-mini",
+ "metadata": {
+ "ls_provider": "other",
+ "ls_model_name": "other-model",
+ },
+ },
+ )
+
+ assert result["ls_provider"] == "openai"
+ assert result["ls_model_name"] == "gpt-4o-mini"
+
+
[email protected]("metadata", [None, "invalid", ["invalid"]])
+def test_infer_invocation_params_ignores_non_mapping_metadata(metadata):
+ result = _infer_invocation_params(
+ "chat",
+ "openai",
+ {},
+ False,
+ {"model": "gpt-4o-mini", "metadata": metadata},
+ )
+
+ assert result["ls_provider"] == "openai"
+ assert result["ls_model_name"] == "gpt-4o-mini"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/langsmith-0.10.5/tests/unit_tests/wrappers/test_pipecat.py
new/langsmith-0.10.6/tests/unit_tests/wrappers/test_pipecat.py
--- old/langsmith-0.10.5/tests/unit_tests/wrappers/test_pipecat.py
2020-02-02 01:00:00.000000000 +0100
+++ new/langsmith-0.10.6/tests/unit_tests/wrappers/test_pipecat.py
2020-02-02 01:00:00.000000000 +0100
@@ -7,11 +7,14 @@
required (only ``opentelemetry-sdk``, a dev dependency).
"""
+import asyncio
import base64
import io
import json
import sys
import wave
+from datetime import datetime
+from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
@@ -30,22 +33,38 @@
)
-def _make_span(name: str, attributes: dict | None = None, trace_id: int = 0x1):
+def _ns(iso: str) -> int:
+ """Epoch nanoseconds for an ISO 8601 timestamp (the transcript sort
scale)."""
+ return int(datetime.fromisoformat(iso).timestamp() * 1e9)
+
+
+def _make_span(
+ name: str,
+ attributes: dict | None = None,
+ trace_id: int = 0x1,
+ start_time: int = 0,
+):
"""Build a mock span for the processor.
- The processor only *reads* ``span.attributes`` (and a few read-only
fields);
- it never mutates the span. Translation accumulates on a ``TranslatedSpan``
- draft, and a fresh span is built for export — so the rewritten attributes
are
- read off the exported span (see :func:`_exported_attrs`), not this input.
The
- remaining ``ReadableSpan`` fields are auto-mocked, which is all
- ``TranslatedSpan.finalize`` needs to construct the export span.
+ The processor copies the span into a ``TranslatedSpan`` draft and builds a
+ fresh span for export, leaving this input unchanged. The remaining
+ ``ReadableSpan`` fields are auto-mocked, which is all ``finalize`` needs.
+ ``start_time`` (epoch ns) is the transcript sort key for span-sourced
+ messages; pass it (e.g. via :func:`_ns`) when a test asserts ordering.
"""
span = MagicMock()
span.name = name
+ span._name = name
span.attributes = dict(attributes or {})
span.context = MagicMock()
span.context.trace_id = trace_id
span.events = []
+ span.end_time = None
+ span._end_time = None
+ span._start_time = start_time
+ type(span).name = property(lambda value: value._name)
+ type(span).end_time = property(lambda value: value._end_time)
+ type(span).start_time = property(lambda value: value._start_time)
return span
@@ -241,6 +260,244 @@
proc.downstream.on_end.assert_called_once()
+class TestPipecatRealtimeHistory:
+ """OpenAI realtime ``llm_request`` contributes only the tool round-trip."""
+
+ def test_llm_request_contributes_tool_structure_only(self):
+ # OpenAI realtime emits no per-tool spans, so the assistant tool call
and
+ # its result are taken from the llm_request context snapshot. The user
and
+ # plain-assistant messages in that snapshot are ignored (they come from
+ # the aggregator and llm_response); the spoken reply arrives on
+ # llm_response. Ordering is by span time: request (t=1) then response
(t=2).
+ proc = _processor()
+ trace_id = 0xDEF
+ history = [
+ {"role": "system", "content": "be nice"},
+ {"role": "user", "content": "weather?"},
+ {
+ "role": "assistant",
+ "content": None,
+ "tool_calls": [
+ {
+ "id": "c1",
+ "type": "function",
+ "function": {
+ "name": "get_weather",
+ "arguments": '{"city":"SF"}',
+ },
+ }
+ ],
+ },
+ {"role": "tool", "tool_call_id": "c1", "content": '{"temp": 68}'},
+ ]
+ req = _make_span(
+ "llm_request",
+ {"input": json.dumps(history)},
+ trace_id=trace_id,
+ start_time=_ns("2026-01-01T00:00:01+00:00"),
+ )
+ proc.on_end(req)
+ # The llm_request span itself stays a chain wrapper (usage is on
response).
+ assert _exported_attrs(proc)["langsmith.span.kind"] == "chain"
+
+ resp = _make_span(
+ "llm_response",
+ {"output": "it's 68"},
+ trace_id=trace_id,
+ start_time=_ns("2026-01-01T00:00:02+00:00"),
+ )
+ proc.on_end(resp)
+
+ convo = _make_span("conversation", {"conversation.id": "c"},
trace_id=trace_id)
+ proc.on_end(convo)
+
+ prompt = json.loads(_exported_attrs(proc)["gen_ai.prompt"])["messages"]
+ # No user turn (that needs the aggregator); tool call + result + reply.
+ assert [m["role"] for m in prompt] == ["assistant", "tool",
"assistant"]
+ assert prompt[0]["tool_calls"][0]["function"]["name"] == "get_weather"
+ assert prompt[1]["content"] == '{"temp": 68}' # tool result rendered
+ assert prompt[2]["content"] == "it's 68" # spoken reply appended
+
+ def test_llm_request_dedupes_tool_round_trip_across_snapshots(self):
+ # Each snapshot is the full history, so the same tool call recurs.
Dedup by
+ # tool_call_id keeps one assistant-call + one result, not one per
snapshot.
+ proc = _processor()
+ tid = 0x1
+ first = [
+ {
+ "role": "assistant",
+ "content": None,
+ "tool_calls": [
+ {
+ "id": "c1",
+ "type": "function",
+ "function": {"name": "weather", "arguments": "{}"},
+ }
+ ],
+ },
+ {"role": "tool", "tool_call_id": "c1", "content": "sunny"},
+ ]
+ proc.on_end(
+ _make_span(
+ "llm_request",
+ {"input": json.dumps(first)},
+ tid,
+ _ns("2026-01-01T00:00:01+00:00"),
+ )
+ )
+ proc.on_end(
+ _make_span(
+ "llm_request",
+ {"input": json.dumps(first)},
+ tid,
+ _ns("2026-01-01T00:00:02+00:00"),
+ )
+ )
+ proc.on_end(_make_span("conversation", {"conversation.id": "c"},
trace_id=tid))
+
+ prompt = json.loads(_exported_attrs(proc)["gen_ai.prompt"])["messages"]
+ assert [m["role"] for m in prompt] == ["assistant", "tool"]
+
+ def test_cascade_llm_handling_unchanged(self):
+ # Cascade emits `llm`, never `llm_request`; its snapshot+append
behavior
+ # is untouched by the realtime path.
+ proc = _processor()
+ tid = 0x1
+ proc.on_end(
+ _make_span(
+ "llm",
+ {
+ "input": json.dumps([{"role": "user", "content": "q"}]),
+ "output": "a",
+ },
+ trace_id=tid,
+ )
+ )
+ proc.on_end(_make_span("conversation", {"conversation.id": "c"},
trace_id=tid))
+
+ prompt = json.loads(_exported_attrs(proc)["gen_ai.prompt"])["messages"]
+ assert [m["content"] for m in prompt] == ["q", "a"]
+
+
+class TestPipecatToolCalls:
+ """Realtime tool spans: ``llm_tool_call`` + ``llm_tool_result`` merge to
one run."""
+
+ @staticmethod
+ def _exported_spans(proc) -> list:
+ """Every finalized span forwarded downstream, in order."""
+ return [c.args[0] for c in proc.downstream.on_end.call_args_list]
+
+ def test_call_and_result_merge_into_one_span(self):
+ # Pipecat puts no usable id on the result span, so pairing is by order.
+ proc = _processor()
+ trace_id = 0xABC
+ call = _make_span(
+ "llm_tool_call",
+ {"tool.function_name": "get_weather", "tool.arguments": '{"city":
"SF"}'},
+ trace_id=trace_id,
+ )
+
+ # The call span is held (deferred) until its result arrives.
+ proc.on_end(call)
+ assert not proc.downstream.on_end.called
+
+ result = _make_span(
+ "llm_tool_result",
+ {"tool.result": '{"temp": 68}', "tool.result_status": "completed"},
+ trace_id=trace_id,
+ )
+ result._end_time = 999
+
+ proc.on_end(result)
+
+ # One span exported: the merged call span; the result span is dropped.
+ exported = self._exported_spans(proc)
+ assert len(exported) == 1
+ span = exported[0]
+ attrs = dict(span.attributes)
+ assert attrs["langsmith.span.kind"] == "tool"
+ assert span.name == "get_weather"
+ assert attrs["gen_ai.prompt"] == '{"city": "SF"}'
+ assert attrs["gen_ai.completion"] == '{"temp": 68}'
+ assert attrs["langsmith.metadata.tool_result_status"] == "completed"
+ assert span.end_time == 999 # stretched to the result's end
+ assert not proc._tool_calls_by_trace
+
+ def test_result_without_call_exported_standalone(self):
+ # A result with no deferred call still surfaces as a tool run (output
only).
+ proc = _processor()
+ result = _make_span("llm_tool_result", {"tool.result": '{"ok": true}'})
+
+ proc.on_end(result)
+
+ attrs = _exported_attrs(proc)
+ assert attrs["langsmith.span.kind"] == "tool"
+ assert attrs["gen_ai.completion"] == '{"ok": true}'
+ assert "gen_ai.prompt" not in attrs # no args were ever seen
+
+ def test_multiple_calls_pair_in_order(self):
+ # Two sequential calls, two results: each result pairs with the oldest
+ # deferred call (FIFO).
+ proc = _processor()
+ trace_id = 0xABC
+ for name, args in (("a", '{"x": 1}'), ("b", '{"y": 2}')):
+ proc.on_end(
+ _make_span(
+ "llm_tool_call",
+ {"tool.function_name": name, "tool.arguments": args},
+ trace_id=trace_id,
+ )
+ )
+ for res in ('"A"', '"B"'):
+ proc.on_end(
+ _make_span("llm_tool_result", {"tool.result": res},
trace_id=trace_id)
+ )
+
+ exported = self._exported_spans(proc)
+ by_name = {s.name: dict(s.attributes) for s in exported}
+ assert by_name["a"]["gen_ai.prompt"] == '{"x": 1}'
+ assert by_name["a"]["gen_ai.completion"] == '"A"'
+ assert by_name["b"]["gen_ai.prompt"] == '{"y": 2}'
+ assert by_name["b"]["gen_ai.completion"] == '"B"'
+ assert not proc._tool_calls_by_trace
+
+ def test_orphaned_call_flushed_on_conversation_end(self):
+ # A call whose result never arrives is flushed (args only) when the
+ # conversation ends, not held indefinitely.
+ proc = _processor()
+ trace_id = 0xABC
+ call = _make_span(
+ "llm_tool_call",
+ {"tool.function_name": "hang", "tool.arguments": "{}"},
+ trace_id=trace_id,
+ )
+ proc.on_end(call)
+ assert not proc.downstream.on_end.called
+
+ convo = _make_span("conversation", {"conversation.id": "x"},
trace_id=trace_id)
+ proc.on_end(convo)
+
+ exported = self._exported_spans(proc)
+ kinds = [dict(s.attributes).get("langsmith.span.kind") for s in
exported]
+ assert "tool" in kinds # the orphaned call was flushed
+ assert not proc._tool_calls_by_trace
+
+ def test_orphaned_call_flushed_on_shutdown(self):
+ proc = _processor()
+ call = _make_span(
+ "llm_tool_call", {"tool.function_name": "hang", "tool.arguments":
"{}"}
+ )
+ proc.on_end(call)
+ assert not proc.downstream.on_end.called
+
+ proc.shutdown()
+
+ attrs = _exported_attrs(proc)
+ assert attrs["langsmith.span.kind"] == "tool"
+ assert not proc._tool_calls_by_trace
+ proc.downstream.shutdown.assert_called_once()
+
+
class TestPipecatAudio:
"""Audio-buffer registration, accumulation, and root attachment."""
@@ -636,6 +893,301 @@
assert configure_pipecat() is None
+class _FakeUserAggregator:
+ """Minimal Pipecat user aggregator event emitter."""
+
+ def __init__(self):
+ self.handlers = {}
+
+ def event_handler(self, event_name):
+ def decorator(handler):
+ self.handlers[event_name] = handler
+ return handler
+
+ return decorator
+
+ def emit(self, content, timestamp="2026-01-01T00:00:00+00:00"):
+ message = SimpleNamespace(content=content, timestamp=timestamp)
+ asyncio.run(self.handlers["on_user_turn_message_added"](self, message))
+
+
+class _FakeAggregatorPair:
+ """Minimal Pipecat context aggregator pair."""
+
+ def __init__(self):
+ self.user_aggregator = _FakeUserAggregator()
+
+ def user(self):
+ return self.user_aggregator
+
+
+class TestPipecatRealtimeTranscript:
+ """Realtime user events populate response inputs and the root
transcript."""
+
+ def test_aggregator_pair_records_user_turn(self):
+ proc = _processor()
+ trace_id = 0x71
+ proc._remember_thread_id(trace_id, "conv-1")
+ aggregators = _FakeAggregatorPair()
+ proc.instrument_user_aggregator(aggregators, "conv-1")
+
+ aggregators.user().emit("what's the weather?")
+
+ assert proc._render_messages(trace_id) == [
+ {"role": "user", "content": "what's the weather?"}
+ ]
+
+ def test_invalid_aggregator_raises(self):
+ proc = _processor()
+ with pytest.raises(AttributeError):
+ proc.instrument_user_aggregator(object(), "conv-2")
+
+ def test_repeated_finalized_events_are_distinct_turns(self):
+ # Identical utterances are kept as separate turns (no dedup).
+ proc = _processor()
+ trace_id = 0x72
+ proc._remember_thread_id(trace_id, "conv-2")
+ aggregators = _FakeAggregatorPair()
+ proc.instrument_user_aggregator(aggregators, "conv-2")
+
+ aggregators.user().emit("yes", "2026-01-01T00:00:01+00:00")
+ aggregators.user().emit("yes", "2026-01-01T00:00:02+00:00")
+
+ assert proc._render_messages(trace_id) == [
+ {"role": "user", "content": "yes"},
+ {"role": "user", "content": "yes"},
+ ]
+
+ def test_transcript_before_trace_is_buffered_and_cleaned_up(self):
+ proc = _processor()
+ aggregators = _FakeAggregatorPair()
+ proc.instrument_user_aggregator(aggregators, "conv-3")
+ aggregators.user().emit("early words")
+ # Pending entries are (sort_key, message) tuples.
+ assert (
+ proc._pending_user_transcripts["conv-3"][0][1]["content"] ==
"early words"
+ )
+
+ trace_id = 0x73
+ proc._remember_thread_id(trace_id, "conv-3")
+ assert proc._render_messages(trace_id)[0]["content"] == "early words"
+ assert "conv-3" not in proc._pending_user_transcripts
+
+ proc.on_end(_make_span("conversation", {"conversation.id": "conv-3"},
trace_id))
+ assert "conv-3" not in proc._trace_by_thread
+ assert trace_id not in proc._transcript_by_trace
+
+ def test_gemini_response_has_history_and_root_has_both_roles(self):
+ proc = _processor()
+ trace_id = 0x74
+ proc._remember_thread_id(trace_id, "conv-4")
+ aggregators = _FakeAggregatorPair()
+ proc.instrument_user_aggregator(aggregators, "conv-4")
+ aggregators.user().emit("hi there", "2026-01-01T00:00:01+00:00")
+
+ proc.on_end(
+ _make_span(
+ "llm_response",
+ {"output": "hello!"},
+ trace_id,
+ _ns("2026-01-01T00:00:02+00:00"),
+ )
+ )
+ response_attrs = _exported_attrs(proc)
+ assert json.loads(response_attrs["gen_ai.prompt"])["messages"] == [
+ {"role": "user", "content": "hi there"}
+ ]
+
+ proc.on_end(_make_span("conversation", {"conversation.id": "conv-4"},
trace_id))
+ root = json.loads(_exported_attrs(proc)["gen_ai.prompt"])["messages"]
+ assert [(message["role"], message["content"]) for message in root] == [
+ ("user", "hi there"),
+ ("assistant", "hello!"),
+ ]
+
+ def
test_user_from_aggregator_tools_from_snapshot_reply_from_response(self):
+ # The three sources compose into one ordered transcript: user
(aggregator),
+ # tool call + result (llm_request snapshot), spoken reply
(llm_response).
+ proc = _processor()
+ trace_id = 0x75
+ proc._remember_thread_id(trace_id, "conv-5")
+ aggregators = _FakeAggregatorPair()
+ proc.instrument_user_aggregator(aggregators, "conv-5")
+ aggregators.user().emit("weather?", "2026-01-01T00:00:01+00:00")
+ history = [
+ {"role": "system", "content": "be brief"},
+ {"role": "user", "content": "weather?"},
+ {
+ "role": "assistant",
+ "content": None,
+ "tool_calls": [
+ {
+ "id": "c1",
+ "type": "function",
+ "function": {"name": "weather", "arguments": "{}"},
+ }
+ ],
+ },
+ {"role": "tool", "tool_call_id": "c1", "content": "sunny"},
+ ]
+ proc.on_end(
+ _make_span(
+ "llm_request",
+ {"input": json.dumps(history)},
+ trace_id,
+ _ns("2026-01-01T00:00:02+00:00"),
+ )
+ )
+ proc.on_end(
+ _make_span(
+ "llm_response",
+ {"output": "It is sunny."},
+ trace_id,
+ _ns("2026-01-01T00:00:03+00:00"),
+ )
+ )
+ proc.on_end(_make_span("conversation", {"conversation.id": "conv-5"},
trace_id))
+
+ prompt = json.loads(_exported_attrs(proc)["gen_ai.prompt"])["messages"]
+ assert [m["role"] for m in prompt] == [
+ "user",
+ "assistant",
+ "tool",
+ "assistant",
+ ]
+ assert prompt[0]["content"] == "weather?" # once, from the aggregator
+ assert prompt[1]["tool_calls"][0]["function"]["name"] == "weather"
+ assert prompt[3]["content"] == "It is sunny."
+
+ def test_snapshot_without_tools_contributes_nothing(self):
+ # A snapshot with only user / plain-assistant messages adds nothing:
those
+ # roles come from the aggregator and llm_response, not llm_request.
+ proc = _processor()
+ trace_id = 0x76
+ proc._remember_thread_id(trace_id, "conv-6")
+ aggregators = _FakeAggregatorPair()
+ proc.instrument_user_aggregator(aggregators, "conv-6")
+ aggregators.user().emit("new question", "2026-01-01T00:00:01+00:00")
+
+ proc.on_end(
+ _make_span(
+ "llm_request",
+ {"input": json.dumps([{"role": "assistant", "content":
"hello"}])},
+ trace_id,
+ _ns("2026-01-01T00:00:02+00:00"),
+ )
+ )
+ proc.on_end(
+ _make_span(
+ "llm_response",
+ {"output": "answer"},
+ trace_id,
+ _ns("2026-01-01T00:00:03+00:00"),
+ )
+ )
+
+ # The snapshot's "hello" is absent; only the aggregator user turn and
the
+ # llm_response reply make up the transcript.
+ assert [(m["role"], m["content"]) for m in
proc._render_messages(trace_id)] == [
+ ("user", "new question"),
+ ("assistant", "answer"),
+ ]
+
+ def test_async_user_turn_sorts_by_speech_time_not_arrival(self):
+ # The regression: OpenAI transcribes late, so the tool round-trip
+ # (llm_request) is processed BEFORE the user transcript arrives — but
the
+ # user spoke first. Keying the user turn by its turn-start timestamp
puts
+ # it ahead of the tool block, regardless of arrival order.
+ proc = _processor()
+ trace_id = 0x7A
+ proc._remember_thread_id(trace_id, "conv-async")
+ aggregators = _FakeAggregatorPair()
+ proc.instrument_user_aggregator(aggregators, "conv-async")
+
+ history = [
+ {
+ "role": "assistant",
+ "content": None,
+ "tool_calls": [
+ {
+ "id": "c1",
+ "type": "function",
+ "function": {"name": "weather", "arguments": "{}"},
+ }
+ ],
+ },
+ {"role": "tool", "tool_call_id": "c1", "content": "sunny"},
+ ]
+ # Tool round-trip arrives first (t=3)...
+ proc.on_end(
+ _make_span(
+ "llm_request",
+ {"input": json.dumps(history)},
+ trace_id,
+ _ns("2026-01-01T00:00:03+00:00"),
+ )
+ )
+ # ...then the late user transcript, stamped when the user started
(t=1).
+ aggregators.user().emit("weather?", "2026-01-01T00:00:01+00:00")
+ proc.on_end(
+ _make_span(
+ "llm_response",
+ {"output": "It is sunny."},
+ trace_id,
+ _ns("2026-01-01T00:00:04+00:00"),
+ )
+ )
+ proc.on_end(
+ _make_span("conversation", {"conversation.id": "conv-async"},
trace_id)
+ )
+
+ root = json.loads(_exported_attrs(proc)["gen_ai.prompt"])["messages"]
+ assert [m["role"] for m in root] == ["user", "assistant", "tool",
"assistant"]
+ assert root[0]["content"] == "weather?" # ahead of the tool call it
triggered
+
+ def test_multi_turn_order_and_empty_transcript(self):
+ proc = _processor()
+ trace_id = 0x77
+ proc._remember_thread_id(trace_id, "conv-7")
+ aggregators = _FakeAggregatorPair()
+ proc.instrument_user_aggregator(aggregators, "conv-7")
+ user = aggregators.user()
+
+ user.emit("") # empty content is ignored
+ user.emit("first", "2026-01-01T00:00:01+00:00")
+ proc.on_end(
+ _make_span(
+ "llm_response",
+ {"output": "one"},
+ trace_id,
+ _ns("2026-01-01T00:00:02+00:00"),
+ )
+ )
+ user.emit("second", "2026-01-01T00:00:03+00:00")
+ proc.on_end(
+ _make_span(
+ "llm_response",
+ {"output": "two"},
+ trace_id,
+ _ns("2026-01-01T00:00:04+00:00"),
+ )
+ )
+
+ rollup = proc._render_messages(trace_id)
+ assert [(message["role"], message["content"]) for message in rollup]
== [
+ ("user", "first"),
+ ("assistant", "one"),
+ ("user", "second"),
+ ("assistant", "two"),
+ ]
+ second_prompt =
json.loads(_exported_attrs(proc)["gen_ai.prompt"])["messages"]
+ assert [(message["role"], message["content"]) for message in
second_prompt] == [
+ ("user", "first"),
+ ("assistant", "one"),
+ ("user", "second"),
+ ]
+
+
class TestPipecatUsageCapture:
"""Token/usage capture for cost: LLM usage + detail, realtime spans."""
@@ -682,21 +1234,30 @@
completion = json.loads(attrs["gen_ai.completion"])["messages"]
assert completion[0]["content"] == "the weather is sunny"
# Reply folds into the conversation the root renders.
- assert (
- proc._transcript_by_trace[trace_id][-1]["content"] == "the weather
is sunny"
- )
+ assert proc._render_messages(trace_id)[-1]["content"] == "the weather
is sunny"
def test_realtime_rollup_has_user_and_agent_turns(self):
- # Realtime has no cascade `llm` span carrying history: the user turn
- # arrives on a standard `stt` span, the agent reply on `llm_response`.
- # Both must fold into the conversation rollup the root renders.
+ # Without an instrumented aggregator, a finalized `stt` span folds the
user
+ # turn and `llm_response` folds the agent reply; both roll up on the
root.
proc = _processor()
trace_id = 0x56
- proc.on_end(_make_span("stt", {"transcript": "what's the weather?"},
trace_id))
proc.on_end(
- _make_span("llm_response", {"output": "it's sunny"},
trace_id=trace_id)
+ _make_span(
+ "stt",
+ {"transcript": "what's the weather?"},
+ trace_id,
+ _ns("2026-01-01T00:00:01+00:00"),
+ )
+ )
+ proc.on_end(
+ _make_span(
+ "llm_response",
+ {"output": "it's sunny"},
+ trace_id,
+ _ns("2026-01-01T00:00:02+00:00"),
+ )
)
- rollup = proc._transcript_by_trace[trace_id]
+ rollup = proc._render_messages(trace_id)
assert [(m["role"], m["content"]) for m in rollup] == [
("user", "what's the weather?"),
("assistant", "it's sunny"),
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/langsmith-0.10.5/uv.lock new/langsmith-0.10.6/uv.lock
--- old/langsmith-0.10.5/uv.lock 2020-02-02 01:00:00.000000000 +0100
+++ new/langsmith-0.10.6/uv.lock 2020-02-02 01:00:00.000000000 +0100
@@ -14,6 +14,9 @@
exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included
for backwards compatibility when using relative exclude-newer values.
exclude-newer-span = "P1D"
+[manifest]
+overrides = [{ name = "json-repair", specifier = ">=0.60.1" }]
+
[[package]]
name = "aiofiles"
version = "25.1.0"
@@ -1525,11 +1528,11 @@
[[package]]
name = "json-repair"
-version = "0.59.10"
+version = "0.61.4"
source = { registry = "https://pypi.org/simple" }
-sdist = { url =
"https://files.pythonhosted.org/packages/d3/7c/e95bb03068572146eba37e8175c760f470ea0a6097310e16bbf2bc6e6457/json_repair-0.59.10.tar.gz",
hash =
"sha256:2e4b85537c752d8a513ea28fdad891e5ede32c83de745366b97f648b8c34ede7", size
= 49133, upload-time = "2026-05-14T06:41:51.222Z" }
+sdist = { url =
"https://files.pythonhosted.org/packages/f0/f5/68b92610453eae5087a05a6f4123f0477dc2f3e84250c2d7de05552fa12a/json_repair-0.61.4.tar.gz",
hash =
"sha256:d78c212c1d72606bee30a7886820c9d6f7dbd659883dc2397304735a59f7bf86", size
= 51069, upload-time = "2026-07-12T16:51:11.036Z" }
wheels = [
- { url =
"https://files.pythonhosted.org/packages/ee/87/49b20c6b81493d55c311f711ed87319d0fbad8bd0bbfbe36e52103af36bd/json_repair-0.59.10-py3-none-any.whl",
hash =
"sha256:5468fa3eaadcc9b4a5646776bc4176e2fe5f374b5848a15f468cce3b60e3db0e", size
= 47742, upload-time = "2026-05-14T06:41:49.812Z" },
+ { url =
"https://files.pythonhosted.org/packages/f2/be/5b65e61de2c820e6e24837311249a98465d9b100db8da89f65068ad9e799/json_repair-0.61.4-py3-none-any.whl",
hash =
"sha256:1056d5468a6d4e8bb4498b3244f996c795e2d457fbb30465d71249d3ef7b481a", size
= 49604, upload-time = "2026-07-12T16:51:09.735Z" },
]
[[package]]
@@ -2252,7 +2255,7 @@
[[package]]
name = "mcp"
-version = "1.26.0"
+version = "1.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@@ -2270,9 +2273,9 @@
{ name = "typing-inspection" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
-sdist = { url =
"https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz",
hash =
"sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size
= 608005, upload-time = "2026-01-24T19:40:32.468Z" }
+sdist = { url =
"https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz",
hash =
"sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size
= 638501, upload-time = "2026-06-26T12:57:29.093Z" }
wheels = [
- { url =
"https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl",
hash =
"sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size
= 233615, upload-time = "2026-01-24T19:40:30.652Z" },
+ { url =
"https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl",
hash =
"sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size
= 222620, upload-time = "2026-06-26T12:57:27.218Z" },
]
[[package]]