feat(perplexity): native content-block streaming events

This commit is contained in:
Nick Hollon
2026-06-11 09:28:35 -04:00
parent f7d7e0b756
commit 9f01e8cdde
3 changed files with 627 additions and 1 deletions

View File

@@ -0,0 +1,194 @@
"""Native content-block streaming-event converter for Perplexity.
Builds text and tool-call blocks directly from Perplexity's OpenAI-shaped
delta, feeding the shared `BlockStreamTracker`. Unlike the compat bridge (which
buries tool_calls in `additional_kwargs` and loses citations on the v3 path),
this surfaces tool calls as proper blocks and puts search extras (citations,
search_results, images, etc.) in `message-finish` `response_metadata`.
Usage is **cumulative**: each chunk's `usage` field is a running total, so the
message total is the value from the *last* chunk that carries usage — not an
accumulation across chunks.
Note: Perplexity reasoning is inline `<think>…</think>` text inside `content`;
there is no separate `reasoning_content` field, so it surfaces as plain text.
Citations and other search extras are emitted by the legacy `_stream` path via
`additional_kwargs`. On the v3 path `additional_kwargs` is dropped by the compat
bridge, so this converter puts them in `response_metadata` instead —
native v3 output is a strict superset of what the bridge provides.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from langchain_core.language_models.stream_events import (
BlockStreamTracker,
build_message_finish,
)
from langchain_perplexity.chat_models import _create_usage_metadata
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Iterator
from langchain_protocol.protocol import (
MessageMetadata,
MessagesData,
MessageStartData,
)
def _message_start(message_id: str | None, model: str | None) -> MessageStartData:
# Do not use a provider completion id here: on the v3 path core seeds the
# stream with the LangChain run id, and an empty id lets that stand
# (matching the compat bridge). Only an explicit `message_id` wins.
metadata: MessageMetadata = {"provider": "perplexity"}
if model:
metadata["model"] = model
return {
"event": "message-start",
"role": "ai",
"id": message_id or "",
"metadata": metadata,
}
def _feed_delta(
tracker: BlockStreamTracker, delta: dict[str, Any]
) -> Iterator[MessagesData]:
"""Yield block events for one OpenAI-shaped Perplexity delta."""
if content := delta.get("content"):
yield from tracker.feed("text", {"type": "text", "text": content})
for tc in delta.get("tool_calls") or []:
idx = tc.get("index", 0)
fn = tc.get("function") or {}
args = fn.get("arguments")
yield from tracker.feed(
f"tool:{idx}",
{
"type": "tool_call_chunk",
"id": tc.get("id"),
"name": fn.get("name"),
"args": args or "",
"index": idx,
},
)
def _collect_extras(chunk: dict[str, Any]) -> dict[str, Any]:
"""Build response_metadata extras from the first chunk.
Mirrors the first-chunk block in `_stream`: always includes `citations`
(default `[]`), the present-keyed `images`/`related_questions`/
`search_results`, and the truthy-keyed `videos`/`reasoning_steps`.
"""
extras: dict[str, Any] = {"citations": chunk.get("citations", [])}
for key in ("images", "related_questions", "search_results"):
if key in chunk:
extras[key] = chunk[key]
for key in ("videos", "reasoning_steps"):
if chunk.get(key):
extras[key] = chunk[key]
return extras
def convert_perplexity_stream(
raw: Iterator[Any], *, message_id: str | None = None
) -> Iterator[MessagesData]:
"""Convert a raw Perplexity chat stream to protocol events.
Args:
raw: Raw Perplexity chat-completion stream chunks (dicts or SDK objects).
message_id: Overrides the provider message id on `message-start`.
Yields:
Protocol `MessagesData` lifecycle events.
"""
tracker = BlockStreamTracker()
started = False
latest_usage: dict[str, Any] | None = None
response_metadata: dict[str, Any] = {"model_provider": "perplexity"}
model: str | None = None
first_chunk = True
for chunk in raw:
if not isinstance(chunk, dict):
chunk = chunk.model_dump()
if model is None:
model = chunk.get("model")
if usage := chunk.get("usage"):
# Usage is cumulative; track the latest total — do NOT accumulate.
latest_usage = dict(_create_usage_metadata(usage))
if "num_search_queries" not in response_metadata:
if num_sq := usage.get("num_search_queries"):
response_metadata["num_search_queries"] = num_sq
if "search_context_size" not in response_metadata:
if scs := usage.get("search_context_size"):
response_metadata["search_context_size"] = scs
choices = chunk.get("choices") or []
if len(choices) == 0:
continue
if first_chunk:
response_metadata.update(_collect_extras(chunk))
first_chunk = False
if not started:
started = True
yield _message_start(message_id, model)
choice = choices[0]
yield from _feed_delta(tracker, choice.get("delta") or {})
if finish_reason := choice.get("finish_reason"):
response_metadata["finish_reason"] = finish_reason
if not started:
return
yield from tracker.finish_all()
yield build_message_finish(usage=latest_usage, response_metadata=response_metadata)
async def aconvert_perplexity_stream(
raw: AsyncIterator[Any], *, message_id: str | None = None
) -> AsyncIterator[MessagesData]:
"""Async twin of `convert_perplexity_stream`."""
tracker = BlockStreamTracker()
started = False
latest_usage: dict[str, Any] | None = None
response_metadata: dict[str, Any] = {"model_provider": "perplexity"}
model: str | None = None
first_chunk = True
async for chunk in raw:
if not isinstance(chunk, dict):
chunk = chunk.model_dump()
if model is None:
model = chunk.get("model")
if usage := chunk.get("usage"):
# Usage is cumulative; track the latest total — do NOT accumulate.
latest_usage = dict(_create_usage_metadata(usage))
if "num_search_queries" not in response_metadata:
if num_sq := usage.get("num_search_queries"):
response_metadata["num_search_queries"] = num_sq
if "search_context_size" not in response_metadata:
if scs := usage.get("search_context_size"):
response_metadata["search_context_size"] = scs
choices = chunk.get("choices") or []
if len(choices) == 0:
continue
if first_chunk:
response_metadata.update(_collect_extras(chunk))
first_chunk = False
if not started:
started = True
yield _message_start(message_id, model)
choice = choices[0]
for ev in _feed_delta(tracker, choice.get("delta") or {}):
yield ev
if finish_reason := choice.get("finish_reason"):
response_metadata["finish_reason"] = finish_reason
if not started:
return
for ev in tracker.finish_all():
yield ev
yield build_message_finish(usage=latest_usage, response_metadata=response_metadata)

View File

@@ -6,7 +6,10 @@ import json
import logging
from collections.abc import AsyncIterator, Callable, Iterator, Mapping, Sequence
from operator import itemgetter
from typing import Any, Literal, TypeAlias, cast
from typing import TYPE_CHECKING, Any, Literal, TypeAlias, cast
if TYPE_CHECKING:
from langchain_protocol.protocol import MessagesData
from langchain_core.callbacks import (
AsyncCallbackManagerForLLMRun,
@@ -1369,6 +1372,70 @@ class ChatPerplexity(BaseChatModel):
await run_manager.on_llm_new_token(chunk.text, chunk=chunk)
yield chunk
def _stream_chat_model_events(
self,
messages: list[BaseMessage],
stop: list[str] | None = None,
run_manager: CallbackManagerForLLMRun | None = None,
*,
message_id: str | None = None,
**kwargs: Any,
) -> Iterator[MessagesData]:
"""Emit Perplexity-native content-block protocol events.
Detected by `langchain-core`'s `_iter_v2_events`; powers
`stream_events(version="v3")`. Falls through to the compat bridge
only if this method is absent. `message_id` is threaded from the
stream so `message-start` matches the bridge's LangChain run id.
"""
from langchain_perplexity._stream_events import convert_perplexity_stream
message_dicts, params = self._create_message_dicts(messages, stop)
params = {**params, **kwargs}
params.pop("stream", None)
if stop:
params["stop_sequences"] = stop
raw = self.client.chat.completions.create(
messages=message_dicts, stream=True, **params
)
for event in convert_perplexity_stream(raw, message_id=message_id):
if (
run_manager is not None
and event["event"] == "content-block-delta"
and event["delta"].get("type") == "text-delta"
):
run_manager.on_llm_new_token(str(event["delta"].get("text", "")))
yield event
async def _astream_chat_model_events(
self,
messages: list[BaseMessage],
stop: list[str] | None = None,
run_manager: AsyncCallbackManagerForLLMRun | None = None,
*,
message_id: str | None = None,
**kwargs: Any,
) -> AsyncIterator[MessagesData]:
"""Async twin of `_stream_chat_model_events`."""
from langchain_perplexity._stream_events import aconvert_perplexity_stream
message_dicts, params = self._create_message_dicts(messages, stop)
params = {**params, **kwargs}
params.pop("stream", None)
if stop:
params["stop_sequences"] = stop
raw = await self.async_client.chat.completions.create(
messages=message_dicts, stream=True, **params
)
async for event in aconvert_perplexity_stream(raw, message_id=message_id):
if (
run_manager is not None
and event["event"] == "content-block-delta"
and event["delta"].get("type") == "text-delta"
):
await run_manager.on_llm_new_token(str(event["delta"].get("text", "")))
yield event
def _generate(
self,
messages: list[BaseMessage],

View File

@@ -0,0 +1,365 @@
"""Unit tests for the Perplexity native stream-events converter."""
import os
from typing import Any, cast
from unittest.mock import MagicMock, patch
from langchain_tests.utils.stream_lifecycle import assert_valid_event_stream
from langchain_perplexity import ChatPerplexity
from langchain_perplexity._stream_events import (
aconvert_perplexity_stream,
convert_perplexity_stream,
)
if "PPLX_API_KEY" not in os.environ:
os.environ["PPLX_API_KEY"] = "fake-key"
def _text_with_citations() -> list[dict]:
"""Fixture: plain text response with citations and search_results.
Usage is cumulative — each chunk's `usage` is a running total.
Includes a final `choices: []` chunk carrying only the cumulative total.
"""
m = "sonar"
return [
{
"model": m,
"citations": ["https://example.com/1", "https://example.com/2"],
"search_results": [{"title": "Example", "url": "https://example.com/1"}],
"choices": [
{
"index": 0,
"delta": {"role": "assistant", "content": "Hello "},
"finish_reason": None,
}
],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 2,
"total_tokens": 12,
},
},
{
"model": m,
"choices": [
{
"index": 0,
"delta": {"content": "world"},
"finish_reason": None,
}
],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 5,
"total_tokens": 15,
},
},
{
"model": m,
"choices": [
{
"index": 0,
"delta": {"content": "!"},
"finish_reason": "stop",
}
],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 6,
"total_tokens": 16,
},
},
# Usage-only chunk (choices: []) — final cumulative total. Strictly
# larger than the prior chunk so the test proves message-finish uses
# THIS total (last-wins), not the last choices-chunk's total and not a
# sum of per-chunk deltas.
{
"model": m,
"choices": [],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 7,
"total_tokens": 17,
"num_search_queries": 2,
"search_context_size": "high",
},
},
]
def _tool_call_chunks() -> list[dict]:
"""Fixture: single tool call streamed across two chunks."""
m = "sonar"
return [
{
"model": m,
"choices": [
{
"index": 0,
"delta": {
"role": "assistant",
"tool_calls": [
{
"index": 0,
"id": "call_abc",
"function": {
"name": "get_weather",
"arguments": '{"city":',
},
}
],
},
"finish_reason": None,
}
],
"usage": {"prompt_tokens": 15, "completion_tokens": 3, "total_tokens": 18},
},
{
"model": m,
"choices": [
{
"index": 0,
"delta": {
"tool_calls": [
{"index": 0, "function": {"arguments": ' "Paris"}'}}
]
},
"finish_reason": "tool_calls",
}
],
"usage": {"prompt_tokens": 15, "completion_tokens": 8, "total_tokens": 23},
},
]
def test_convert_perplexity_stream_lifecycle() -> None:
events: list[Any] = list(convert_perplexity_stream(iter(_text_with_citations())))
assert_valid_event_stream(events)
# message-start: empty id (LangChain run id slot), correct provider
assert events[0]["event"] == "message-start"
assert events[0]["id"] == ""
assert events[0]["metadata"]["provider"] == "perplexity"
# Block order: exactly one text block
finishes = [e for e in events if e["event"] == "content-block-finish"]
types = [f["content"]["type"] for f in finishes]
assert types == ["text"]
assert cast("dict[str, Any]", finishes[0]["content"])["text"] == "Hello world!"
# message-finish: usage == LAST cumulative total (the no-choices final
# chunk's 7/17, not the prior choices-chunk's 6/16 nor a sum of deltas)
message_finish = events[-1]
assert message_finish["event"] == "message-finish"
assert message_finish["usage"] == {
"input_tokens": 10,
"output_tokens": 7,
"total_tokens": 17,
}
# Extras present in response_metadata
assert message_finish["metadata"]["model_provider"] == "perplexity"
assert message_finish["metadata"]["citations"] == [
"https://example.com/1",
"https://example.com/2",
]
assert message_finish["metadata"]["search_results"] == [
{"title": "Example", "url": "https://example.com/1"}
]
async def test_aconvert_perplexity_stream_lifecycle() -> None:
async def _araw() -> Any:
for chunk in _text_with_citations():
yield chunk
events: list[Any] = [e async for e in aconvert_perplexity_stream(_araw())]
assert_valid_event_stream(events)
assert events[0]["event"] == "message-start"
assert events[0]["id"] == ""
assert events[0]["metadata"]["provider"] == "perplexity"
finishes = [e for e in events if e["event"] == "content-block-finish"]
types = [f["content"]["type"] for f in finishes]
assert types == ["text"]
assert cast("dict[str, Any]", finishes[0]["content"])["text"] == "Hello world!"
message_finish = events[-1]
assert message_finish["event"] == "message-finish"
# Usage must equal the LAST cumulative total (7/17), not a sum of deltas
assert message_finish["usage"] == {
"input_tokens": 10,
"output_tokens": 7,
"total_tokens": 17,
}
assert message_finish["metadata"]["citations"] == [
"https://example.com/1",
"https://example.com/2",
]
assert message_finish["metadata"]["search_results"] == [
{"title": "Example", "url": "https://example.com/1"}
]
def test_convert_perplexity_stream_tool_call() -> None:
"""Tool calls are surfaced as `tool_call` blocks keyed `tool:{idx}`."""
events: list[Any] = list(convert_perplexity_stream(iter(_tool_call_chunks())))
assert_valid_event_stream(events)
finishes = [e for e in events if e["event"] == "content-block-finish"]
types = [f["content"]["type"] for f in finishes]
assert types == ["tool_call"]
tool = cast("dict[str, Any]", finishes[0]["content"])
assert tool["name"] == "get_weather"
assert tool["args"] == {"city": "Paris"}
def test_perplexity_stream_events_v3_lifecycle() -> None:
"""Drive `stream_events(version="v3")` through the native sync hook.
Confirms the model-level path threads the LangChain run id onto
`message-start` (non-empty, unlike the converter's empty default) and
surfaces text blocks with `model_provider == "perplexity"`.
"""
llm = ChatPerplexity(model="sonar")
mock_client = MagicMock()
def mock_create(*_a: Any, **_k: Any) -> Any:
return iter(_text_with_citations())
mock_client.chat.completions.create = mock_create
with patch.object(llm, "client", mock_client):
stream = llm.stream_events("Test query", version="v3")
events = list(stream)
assert_valid_event_stream(events)
message_start = cast("dict[str, Any]", events[0])
assert message_start["event"] == "message-start"
assert message_start["id"] # core seeds a non-empty LangChain run id
assert message_start["metadata"]["provider"] == "perplexity"
finishes = [e for e in events if e["event"] == "content-block-finish"]
assert [f["content"]["type"] for f in finishes] == ["text"]
message_finish = cast("dict[str, Any]", events[-1])
assert message_finish["event"] == "message-finish"
assert message_finish["metadata"]["model_provider"] == "perplexity"
# Citations ride on message-finish metadata (not additional_kwargs — the v3
# path has no additional_kwargs channel; this is an intentional relocation).
assert message_finish["metadata"]["citations"] == [
"https://example.com/1",
"https://example.com/2",
]
# Round-trip: extras and model_name reach the assembled message's
# response_metadata (the user-facing guarantee of the de-risk design).
output = stream.output
assert output.response_metadata["citations"] == [
"https://example.com/1",
"https://example.com/2",
]
assert output.response_metadata["model_name"] == "sonar"
async def test_perplexity_astream_events_v3_lifecycle() -> None:
"""Async twin of `test_perplexity_stream_events_v3_lifecycle`."""
llm = ChatPerplexity(model="sonar")
async def _araw() -> Any:
for chunk in _text_with_citations():
yield chunk
async def mock_create(*_a: Any, **_k: Any) -> Any:
return _araw()
mock_async_client = MagicMock()
mock_async_client.chat.completions.create = mock_create
with patch.object(llm, "async_client", mock_async_client):
stream = await llm.astream_events("Test query", version="v3")
events = [e async for e in stream]
assert_valid_event_stream(events)
message_start = cast("dict[str, Any]", events[0])
assert message_start["event"] == "message-start"
assert message_start["id"] # non-empty LC run id
assert message_start["metadata"]["provider"] == "perplexity"
finishes = [e for e in events if e["event"] == "content-block-finish"]
assert [f["content"]["type"] for f in finishes] == ["text"]
message_finish = cast("dict[str, Any]", events[-1])
assert message_finish["event"] == "message-finish"
assert message_finish["metadata"]["model_provider"] == "perplexity"
# Citations ride on message-finish metadata (not additional_kwargs — the v3
# path has no additional_kwargs channel; this is an intentional relocation).
assert message_finish["metadata"]["citations"] == [
"https://example.com/1",
"https://example.com/2",
]
output = await stream.output
assert output.response_metadata["citations"] == [
"https://example.com/1",
"https://example.com/2",
]
assert output.response_metadata["model_name"] == "sonar"
def _no_usage_text() -> list[dict]:
"""Fixture: text chunks with no `usage` field at all."""
m = "sonar"
return [
{
"model": m,
"choices": [
{"index": 0, "delta": {"content": "Hi"}, "finish_reason": "stop"}
],
}
]
def test_convert_perplexity_stream_no_usage() -> None:
"""No `usage` on any chunk → message-finish omits the usage field."""
events: list[Any] = list(convert_perplexity_stream(iter(_no_usage_text())))
assert_valid_event_stream(events)
message_finish = events[-1]
assert message_finish["event"] == "message-finish"
assert message_finish.get("usage") is None
def test_convert_perplexity_stream_usage_only_yields_nothing() -> None:
"""A stream with only `choices: []` usage chunks yields no events."""
usage_only = [
{"model": "sonar", "choices": [], "usage": {"total_tokens": 5}},
{"model": "sonar", "choices": [], "usage": {"total_tokens": 9}},
]
assert list(convert_perplexity_stream(iter(usage_only))) == []
def test_convert_perplexity_stream_accepts_sdk_objects() -> None:
"""Non-dict chunks are normalized via `model_dump()`."""
class _Chunk:
def __init__(self, data: dict) -> None:
self._data = data
def model_dump(self) -> dict:
return self._data
raw = [_Chunk(c) for c in _text_with_citations()]
events: list[Any] = list(convert_perplexity_stream(iter(raw)))
assert_valid_event_stream(events)
finishes = [e for e in events if e["event"] == "content-block-finish"]
assert cast("dict[str, Any]", finishes[0]["content"])["text"] == "Hello world!"
assert events[-1]["metadata"]["citations"] == [
"https://example.com/1",
"https://example.com/2",
]