diff --git a/libs/partners/perplexity/langchain_perplexity/chat_models.py b/libs/partners/perplexity/langchain_perplexity/chat_models.py index c2135433c94..53f73a81ec0 100644 --- a/libs/partners/perplexity/langchain_perplexity/chat_models.py +++ b/libs/partners/perplexity/langchain_perplexity/chat_models.py @@ -4,7 +4,7 @@ from __future__ import annotations import json import logging -from collections.abc import AsyncIterator, Iterator, Mapping +from collections.abc import AsyncIterator, Callable, Iterator, Mapping, Sequence from operator import itemgetter from typing import Any, Literal, TypeAlias, cast @@ -42,10 +42,15 @@ from langchain_core.messages.ai import ( UsageMetadata, subtract_usage, ) +from langchain_core.messages.tool import tool_call_chunk from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult from langchain_core.runnables import Runnable, RunnableMap, RunnablePassthrough +from langchain_core.tools import BaseTool from langchain_core.utils import get_pydantic_field_names, secret_from_env -from langchain_core.utils.function_calling import convert_to_json_schema +from langchain_core.utils.function_calling import ( + convert_to_json_schema, + convert_to_openai_tool, +) from langchain_core.utils.pydantic import is_basemodel_subclass from perplexity import AsyncPerplexity, Perplexity from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator @@ -167,6 +172,112 @@ def _is_builtin_tool(tool: dict) -> bool: return "type" in tool and tool["type"] != "function" +def _flatten_responses_tool(tool: dict) -> dict: + """Flatten a Chat-Completions function tool (nested under `function`) to + the Responses-API's flat shape. Built-in tools (e.g. `web_search`) pass + through unchanged. + """ + if tool.get("type") == "function" and isinstance(tool.get("function"), dict): + fn = tool["function"] + flat: dict[str, Any] = {"type": "function", "name": fn.get("name")} + for key in ("description", "parameters", "strict"): + if key in fn: + flat[key] = fn[key] + return flat + return tool + + +def _content_to_text(content: Any) -> str: + """Concatenate text from a string or list-of-blocks content, dropping + non-text blocks (e.g. a `tool_call`/`tool_use` block) that the Responses API + can't take on a tool turn. + + Only the optional plain-text preamble of an assistant tool turn is built + here; the calls themselves are re-materialized as `function_call` items by + `_translate_responses_input`, so nothing actionable is lost. + """ + if isinstance(content, str): + return content + if isinstance(content, list): + parts: list[str] = [] + for block in content: + if isinstance(block, str): + parts.append(block) + elif isinstance(block, dict) and block.get("type") == "text": + parts.append(block.get("text", "")) + return "".join(parts) + if content is not None: + # An unexpected content shape (not str/list/None) is dropped rather than + # guessed at; log it so content-shape drift stays diagnosable. + logger.debug("Dropping unexpected content type %s on tool turn.", type(content)) + return "" + + +def _translate_responses_input(message_dicts: list[dict[str, Any]]) -> list[Any]: + """Translate Chat-Completions message dicts into Responses-API input items. + + The Responses API has no `tool` role: an assistant turn's `tool_calls` + become `function_call` items and a `tool` message becomes a + `function_call_output`. Other messages pass through. + + `name`, `id`, and `tool_call_id` are the fields that pair a call with its + result; `_convert_message_to_dict` always populates them, so a missing one + here signals upstream drift or a hand-built message and is logged at + `WARNING` rather than silently coerced. + """ + translated: list[Any] = [] + for message in message_dicts: + if not isinstance(message, dict): + translated.append(message) + continue + role = message.get("role") + if role == "assistant" and message.get("tool_calls"): + # Assistant text (if any) becomes a plain message; the calls follow + # as `function_call` items. + text = _content_to_text(message.get("content")) + if text: + translated.append({"role": "assistant", "content": text}) + for tool_call in message["tool_calls"]: + function = tool_call.get("function", {}) + call_id = tool_call.get("id") + name = function.get("name", "") + if not name or not call_id: + logger.warning( + "Assistant tool_call missing identity field " + "(name=%r, id=%r); the Responses API may reject this " + "turn or fail to pair the call with its output.", + name, + call_id, + ) + translated.append( + { + "type": "function_call", + "call_id": call_id, + "name": name, + "arguments": function.get("arguments", "") or "", + } + ) + elif role == "tool": + content = message.get("content", "") + output = content if isinstance(content, str) else json.dumps(content) + call_id = message.get("tool_call_id") + if not call_id: + logger.warning( + "Tool message missing tool_call_id; the Responses API " + "cannot pair this function_call_output with its call." + ) + translated.append( + { + "type": "function_call_output", + "call_id": call_id, + "output": output, + } + ) + else: + translated.append(message) + return translated + + def _use_responses_api(payload: dict) -> bool: """Determine whether to route a payload through the Responses API. @@ -201,6 +312,14 @@ def _use_responses_api(payload: dict) -> bool: return False +def _set_model_name_alias(response_metadata: dict[str, Any]) -> None: + """Mirror `model` into `model_name`, which langchain-core usage callbacks + read for cost tracking (the Chat Completions path already sets it). + """ + if "model" in response_metadata: + response_metadata["model_name"] = response_metadata["model"] + + def _get_attr(obj: Any, name: str, default: Any = None) -> Any: """Safely fetch an attribute from an SDK object or a dict. @@ -319,6 +438,7 @@ def _convert_responses_to_chat_result(response: Any) -> ChatResult: value = _get_attr(response, key, None) if value is not None: response_metadata[key] = value + _set_model_name_alias(response_metadata) message = AIMessage( content=content, @@ -362,17 +482,37 @@ def _convert_responses_stream_event_to_chunk( ) -> ChatGenerationChunk | None: """Convert a Responses API streaming event to a `ChatGenerationChunk`. - Handles `response.output_text.delta` (text chunk), `response.completed` + Handles `response.output_text.delta` (text chunk), `response.output_item.done` + carrying a `function_call` (surfaced as a tool-call chunk), `response.completed` (final usage + metadata), and `response.failed` / `response.error` (raises `PerplexityResponsesStreamError`). Returns `None` for any other - event type — including function-call streaming events, which are - intentionally not surfaced as chunks today; unrecognized event types are - logged at `DEBUG` so SDK drift is diagnosable without flooding logs. + event type; unrecognized event types are logged at `DEBUG` so SDK drift is + diagnosable without flooding logs. """ event_type = _get_attr(event, "type", None) if event_type == "response.output_text.delta": delta = _get_attr(event, "delta", "") or "" return ChatGenerationChunk(message=AIMessageChunk(content=delta)) + if event_type == "response.output_item.done": + item = _get_attr(event, "item", None) + if item is not None and _get_attr(item, "type", None) == "function_call": + # The Responses API delivers the whole function call in one item + # (no argument deltas), so emit it as a single tool-call chunk. + return ChatGenerationChunk( + message=AIMessageChunk( + content="", + tool_call_chunks=[ + tool_call_chunk( + name=_get_attr(item, "name", None), + args=_get_attr(item, "arguments", None), + id=_get_attr(item, "call_id", None) + or _get_attr(item, "id", None), + index=_get_attr(event, "output_index", 0), + ) + ], + ) + ) + return None if event_type == "response.completed": response = _get_attr(event, "response", None) usage_metadata = _convert_responses_usage(_get_attr(response, "usage", None)) @@ -383,6 +523,7 @@ def _convert_responses_stream_event_to_chunk( value = _get_attr(response, key, None) if value is not None: response_metadata[key] = value + _set_model_name_alias(response_metadata) for key in ( "citations", "images", @@ -888,7 +1029,7 @@ class ChatPerplexity(BaseChatModel): `dict` — silently dropping subsequent params would mask user-set search/filter knobs. """ - payload: dict[str, Any] = {"input": message_dicts} + payload: dict[str, Any] = {"input": _translate_responses_input(message_dicts)} runtime_keys = user_set_keys or set() user_set_temperature = ( "temperature" in self.model_fields_set or "temperature" in runtime_keys @@ -901,6 +1042,13 @@ class ChatPerplexity(BaseChatModel): continue if key == "messages": continue + if key in _RESPONSES_DROP_KEYS: + # Suppress the warning for the class-default `temperature`, + # which `_default_params` injects on every call and would + # otherwise spam users who never asked for it. + if key != "temperature" or user_set_temperature: + dropped_for_warning[key] = value + continue if key == "tool_choice": msg = ( "Perplexity Responses (Agent) API does not support " @@ -910,16 +1058,14 @@ class ChatPerplexity(BaseChatModel): "decide." ) raise ValueError(msg) - if key in _RESPONSES_DROP_KEYS: - # Suppress the warning for the class-default `temperature`, - # which `_default_params` injects on every call and would - # otherwise spam users who never asked for it. - if key != "temperature" or user_set_temperature: - dropped_for_warning[key] = value - continue if key == "max_tokens": payload["max_output_tokens"] = value continue + if key == "tools": + # Function tools must be flattened to the Responses-API shape; + # built-in tools (web_search, etc.) pass through unchanged. + payload["tools"] = [_flatten_responses_tool(tool) for tool in value] + continue if key in _RESPONSES_PASSTHROUGH_KEYS: payload[key] = value continue @@ -1363,6 +1509,74 @@ class ChatPerplexity(BaseChatModel): """Return type of chat model.""" return "perplexitychat" + def bind_tools( + self, + tools: Sequence[dict[str, Any] | type | Callable | BaseTool], + *, + tool_choice: dict | str | bool | None = None, + strict: bool | None = None, + **kwargs: Any, + ) -> Runnable[LanguageModelInput, AIMessage]: + """Bind tool-like objects to this chat model. + + Client-side function tools require the Perplexity Responses (Agent) API: + construct the model with `use_responses_api=True` and a tool-capable + model such as `openai/gpt-5.5`. The `sonar` family does not support + client-side function tools. + + Args: + tools: A list of tool definitions to bind to this chat model. + Supports any tool handled by + [convert_to_openai_tool][langchain_core.utils.function_calling.convert_to_openai_tool] + (Pydantic models, `TypedDict` classes, callables, `BaseTool`, + or OpenAI-format dicts), as well as Perplexity built-in tools such + as `{"type": "web_search"}`, which are passed through unchanged. + tool_choice: Which tool the model should use. Normalized here for API + parity with `langchain-openai` (a tool name, `"auto"`, `"none"`, + `"any"`/`"required"`/`True`, or an OpenAI-style dict) and stored + on the binding, but the Perplexity Responses (Agent) API does not + currently honor it: a non-empty `tool_choice` makes + `_to_responses_payload` raise `ValueError` at invoke time on the + Responses route. The restriction can be relaxed if Perplexity + adds `tool_choice` support. + strict: If `True`, the tool parameter schema is sent with `strict` + enabled. If `None` (default), the flag is omitted. + kwargs: Any additional parameters are passed directly to `bind`. + """ + formatted_tools = [ + tool + if isinstance(tool, dict) and _is_builtin_tool(tool) + else convert_to_openai_tool(tool, strict=strict) + for tool in tools + ] + if tool_choice: + tool_names = [ + t["function"]["name"] if "function" in t else t.get("name") + for t in formatted_tools + ] + if isinstance(tool_choice, str): + if tool_choice in tool_names: + tool_choice = { + "type": "function", + "function": {"name": tool_choice}, + } + # 'any' is not native to the OpenAI schema; map it to 'required' + # for parity with providers that use 'any'. + elif tool_choice == "any": + tool_choice = "required" + elif isinstance(tool_choice, bool): + tool_choice = "required" + elif isinstance(tool_choice, dict): + pass + else: + msg = ( + "Unrecognized tool_choice type. Expected str, bool or dict. " + f"Received: {tool_choice}" + ) + raise ValueError(msg) + kwargs["tool_choice"] = tool_choice + return super().bind(tools=formatted_tools, **kwargs) + def with_structured_output( self, schema: _DictOrPydanticClass | None = None, diff --git a/libs/partners/perplexity/tests/integration_tests/test_chat_models_standard.py b/libs/partners/perplexity/tests/integration_tests/test_chat_models_standard.py index ea48ad4e621..2fbdaae72b2 100644 --- a/libs/partners/perplexity/tests/integration_tests/test_chat_models_standard.py +++ b/libs/partners/perplexity/tests/integration_tests/test_chat_models_standard.py @@ -1,5 +1,7 @@ """Standard LangChain interface tests.""" +from typing import Any + import pytest from langchain_core.language_models import BaseChatModel from langchain_tests.integration_tests import ChatModelIntegrationTests @@ -16,6 +18,14 @@ class TestPerplexityStandard(ChatModelIntegrationTests): def chat_model_params(self) -> dict: return {"model": "sonar"} + @property + def has_tool_calling(self) -> bool: + # The sonar family does not support client-side function tools: the API + # returns 400 "Tool calling is not supported for this model". Tool + # calling is exercised by TestPerplexityResponsesStandard below, which + # runs against the Responses (Agent) API. + return False + @pytest.mark.xfail(reason="TODO: handle in integration.") def test_double_messages_conversation(self, model: BaseChatModel) -> None: super().test_double_messages_conversation(model) @@ -25,3 +35,46 @@ class TestPerplexityStandard(ChatModelIntegrationTests): super().test_stop_sequence(model) # TODO, API regressed for some reason after 2025-04-15 + + +class TestPerplexityResponsesStandard(ChatModelIntegrationTests): + """Standard tests on the Responses (Agent) API, which supports tool calling. + + Client-side function tools require the Responses route and a tool-capable + model (the `sonar` family does not support them), so the tool-calling test + family runs here rather than on `TestPerplexityStandard`. + """ + + @property + def chat_model_class(self) -> type[BaseChatModel]: + return ChatPerplexity + + @property + def chat_model_params(self) -> dict: + return {"model": "openai/gpt-5.5", "use_responses_api": True} + + @property + def has_tool_choice(self) -> bool: + # The Perplexity Responses (Agent) API does not support `tool_choice` + # (`_to_responses_payload` raises `ValueError`), so forced tool + # selection cannot be used here. The model still calls tools when the + # prompt warrants it, which is what the tool tests assert. + return False + + # These two tests hard-code `tool_choice="any"` (they are not gated by + # `has_tool_choice`) to force a tool call, which the Responses route rejects + # with `ValueError`. They are xfailed here rather than disabling the whole + # tool-calling family; every other tool test runs and passes. + @pytest.mark.xfail( + reason="Responses (Agent) API does not support tool_choice; this test " + "hard-codes tool_choice='any'." + ) + def test_unicode_tool_call_integration(self, *args: Any, **kwargs: Any) -> None: + super().test_unicode_tool_call_integration(*args, **kwargs) + + @pytest.mark.xfail( + reason="Responses (Agent) API does not support tool_choice; this test " + "hard-codes tool_choice='any'." + ) + def test_structured_few_shot_examples(self, *args: Any, **kwargs: Any) -> None: + super().test_structured_few_shot_examples(*args, **kwargs) diff --git a/libs/partners/perplexity/tests/unit_tests/test_chat_models.py b/libs/partners/perplexity/tests/unit_tests/test_chat_models.py index db48c171e8f..63068341210 100644 --- a/libs/partners/perplexity/tests/unit_tests/test_chat_models.py +++ b/libs/partners/perplexity/tests/unit_tests/test_chat_models.py @@ -2,16 +2,25 @@ import json from typing import Any, cast from unittest.mock import MagicMock +import pytest +from langchain_core.language_models.chat_models import BaseChatModel from langchain_core.messages import ( AIMessage, AIMessageChunk, BaseMessage, ToolMessage, ) +from langchain_core.runnables import RunnableBinding from pytest_mock import MockerFixture from langchain_perplexity import ChatPerplexity, MediaResponse, WebSearchOptions -from langchain_perplexity.chat_models import _create_usage_metadata +from langchain_perplexity.chat_models import ( + _content_to_text, + _convert_responses_stream_event_to_chunk, + _create_usage_metadata, + _flatten_responses_tool, + _translate_responses_input, +) def test_perplexity_model_name_param() -> None: @@ -343,3 +352,302 @@ def test_convert_ai_message_with_valid_and_invalid_tool_calls_to_dict() -> None: "function": {"name": "search", "arguments": "{not valid json"}, }, ] + + +def _weather_tool() -> dict: + return { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get the weather for a city.", + "parameters": { + "type": "object", + "properties": {"location": {"type": "string"}}, + "required": ["location"], + }, + }, + } + + +def _bound_kwargs(bound: Any) -> dict[str, Any]: + """Return the kwargs from the `RunnableBinding` that `bind_tools` produces.""" + assert isinstance(bound, RunnableBinding) + return dict(bound.kwargs) + + +def test_bind_tools_is_overridden() -> None: + """`bind_tools` must be overridden so `langchain-tests` detects tool support. + + The standard suite derives `has_tool_calling` from + `bind_tools is not BaseChatModel.bind_tools`; if this regresses, the entire + tool-calling test family is silently skipped. + """ + assert ChatPerplexity.bind_tools is not BaseChatModel.bind_tools + + +def test_bind_tools_formats_function_tool() -> None: + """A callable is converted to the OpenAI (Chat Completions) function shape.""" + llm = ChatPerplexity(model="test", api_key="test") + + def get_weather(location: str) -> str: + """Get the weather for a city.""" + return "sunny" + + bound = llm.bind_tools([get_weather]) + tools = _bound_kwargs(bound)["tools"] + assert tools[0]["type"] == "function" + assert tools[0]["function"]["name"] == "get_weather" + + +def test_bind_tools_passes_through_builtin_tool() -> None: + """Perplexity built-in tools are bound unchanged, not run through conversion.""" + llm = ChatPerplexity(model="test", api_key="test") + bound = llm.bind_tools([{"type": "web_search"}]) + assert _bound_kwargs(bound)["tools"] == [{"type": "web_search"}] + + +def test_bind_tools_tool_choice_by_name() -> None: + llm = ChatPerplexity(model="test", api_key="test") + bound = llm.bind_tools([_weather_tool()], tool_choice="get_weather") + assert _bound_kwargs(bound)["tool_choice"] == { + "type": "function", + "function": {"name": "get_weather"}, + } + + +def test_bind_tools_tool_choice_any_and_bool() -> None: + llm = ChatPerplexity(model="test", api_key="test") + any_bound = llm.bind_tools([_weather_tool()], tool_choice="any") + assert _bound_kwargs(any_bound)["tool_choice"] == "required" + true_bound = llm.bind_tools([_weather_tool()], tool_choice=True) + assert _bound_kwargs(true_bound)["tool_choice"] == "required" + + +def test_bind_tools_tool_choice_invalid_raises() -> None: + llm = ChatPerplexity(model="test", api_key="test") + with pytest.raises(ValueError, match="Unrecognized tool_choice"): + llm.bind_tools([_weather_tool()], tool_choice=123) # type: ignore[arg-type] + + +def test_content_to_text() -> None: + """List content is reduced to text; tool_use and other blocks are dropped.""" + assert _content_to_text("hello") == "hello" + assert ( + _content_to_text( + [ + {"type": "text", "text": "some text"}, + {"type": "tool_use", "id": "1", "name": "f", "input": {}}, + ] + ) + == "some text" + ) + assert _content_to_text([]) == "" + assert _content_to_text(None) == "" + + +def test_flatten_responses_tool() -> None: + """Function tools are flattened for the Responses API; built-ins pass through.""" + assert _flatten_responses_tool(_weather_tool()) == { + "type": "function", + "name": "get_weather", + "description": "Get the weather for a city.", + "parameters": { + "type": "object", + "properties": {"location": {"type": "string"}}, + "required": ["location"], + }, + } + assert _flatten_responses_tool({"type": "web_search"}) == {"type": "web_search"} + + +def test_translate_responses_input_tool_roundtrip() -> None: + """Tool turns become typed Responses input items (no `tool` role exists).""" + message_dicts: list[dict[str, Any]] = [ + {"role": "user", "content": "hi"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "get_weather", + "arguments": '{"location": "Paris"}', + }, + } + ], + }, + {"role": "tool", "content": "18C cloudy", "tool_call_id": "call_1"}, + ] + translated = _translate_responses_input(message_dicts) + assert translated[0] == {"role": "user", "content": "hi"} + # Empty/None assistant content is dropped; only the function_call item remains. + assert translated[1] == { + "type": "function_call", + "call_id": "call_1", + "name": "get_weather", + "arguments": '{"location": "Paris"}', + } + assert translated[2] == { + "type": "function_call_output", + "call_id": "call_1", + "output": "18C cloudy", + } + + +def test_translate_responses_input_keeps_assistant_text_with_tool_calls() -> None: + """An assistant turn with both text and tool_calls emits the text first.""" + translated = _translate_responses_input( + [ + { + "role": "assistant", + "content": "Let me check.", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "get_weather", "arguments": "{}"}, + } + ], + } + ] + ) + assert translated[0] == {"role": "assistant", "content": "Let me check."} + assert translated[1]["type"] == "function_call" + assert translated[1]["call_id"] == "call_1" + + +def test_to_responses_payload_flattens_tools_and_translates_messages() -> None: + """End-to-end: `_to_responses_payload` flattens tools and translates tool turns.""" + llm = ChatPerplexity(model="openai/gpt-5.5", api_key="test", use_responses_api=True) + message_dicts: list[dict[str, Any]] = [ + {"role": "user", "content": "weather in Paris?"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "get_weather", + "arguments": '{"location": "Paris"}', + }, + } + ], + }, + {"role": "tool", "content": "18C cloudy", "tool_call_id": "call_1"}, + ] + payload = llm._to_responses_payload(message_dicts, {"tools": [_weather_tool()]}) + # tools flattened to the Responses shape + assert payload["tools"] == [ + { + "type": "function", + "name": "get_weather", + "description": "Get the weather for a city.", + "parameters": { + "type": "object", + "properties": {"location": {"type": "string"}}, + "required": ["location"], + }, + } + ] + # tool turns translated into typed input items, with call_id pairing preserved + fc = [i for i in payload["input"] if i.get("type") == "function_call"] + fco = [i for i in payload["input"] if i.get("type") == "function_call_output"] + assert len(fc) == 1 + assert len(fco) == 1 + assert fc[0]["call_id"] == fco[0]["call_id"] == "call_1" + assert fc[0]["name"] == "get_weather" + assert fc[0]["arguments"] == '{"location": "Paris"}' + + +def test_convert_responses_stream_event_emits_tool_call_chunk() -> None: + """A streamed `function_call` output item becomes a tool-call chunk.""" + event = { + "type": "response.output_item.done", + "output_index": 0, + "item": { + "type": "function_call", + "call_id": "call_1", + "id": "item_1", + "name": "get_weather", + "arguments": '{"location": "Paris"}', + }, + } + chunk = _convert_responses_stream_event_to_chunk(event) + assert chunk is not None + message = chunk.message + assert isinstance(message, AIMessageChunk) + tcc = message.tool_call_chunks + assert len(tcc) == 1 + assert tcc[0]["name"] == "get_weather" + assert tcc[0]["args"] == '{"location": "Paris"}' + assert tcc[0]["id"] == "call_1" + assert tcc[0]["index"] == 0 + + +def test_convert_responses_stream_event_aggregates_multiple_tool_calls() -> None: + """Distinct Responses output items aggregate as distinct tool calls. + + `call_id`/`id` are intentionally omitted so that `index` (derived from each + event's `output_index`) is the *only* thing separating the two calls. This + keeps the test sensitive to the indexing logic: with a hardcoded + ``index=0`` the chunks would merge into one corrupted call. Real streams + always carry a unique `call_id`, which would keep the calls distinct on its + own, so this payload isolates the mechanism rather than mirroring the wire + format. + """ + events = [ + { + "type": "response.output_item.done", + "output_index": 0, + "item": { + "type": "function_call", + "name": "get_weather", + "arguments": '{"location": "Paris"}', + }, + }, + { + "type": "response.output_item.done", + "output_index": 1, + "item": { + "type": "function_call", + "name": "get_population", + "arguments": '{"location": "Paris"}', + }, + }, + ] + chunks = [ + chunk + for event in events + if (chunk := _convert_responses_stream_event_to_chunk(event)) is not None + ] + + message = chunks[0].message + chunks[1].message + + assert isinstance(message, AIMessageChunk) + assert message.tool_calls == [ + { + "name": "get_weather", + "args": {"location": "Paris"}, + "id": None, + "type": "tool_call", + }, + { + "name": "get_population", + "args": {"location": "Paris"}, + "id": None, + "type": "tool_call", + }, + ] + + +def test_convert_responses_stream_event_ignores_non_function_items() -> None: + """Non-function output items (e.g. messages) do not yield tool-call chunks.""" + event = { + "type": "response.output_item.done", + "item": {"type": "message", "content": "hi"}, + } + assert _convert_responses_stream_event_to_chunk(event) is None diff --git a/libs/partners/perplexity/tests/unit_tests/test_chat_models_responses.py b/libs/partners/perplexity/tests/unit_tests/test_chat_models_responses.py index a3c78333bb1..38e375be25c 100644 --- a/libs/partners/perplexity/tests/unit_tests/test_chat_models_responses.py +++ b/libs/partners/perplexity/tests/unit_tests/test_chat_models_responses.py @@ -1020,3 +1020,19 @@ async def test_ainvoke_routes_to_responses_when_builtin_tool_in_payload() -> Non assert "temperature" not in call_kwargs assert "temperature" not in (call_kwargs.get("extra_body") or {}) chat_create.assert_not_called() + + +def test_convert_responses_to_chat_result_sets_model_name() -> None: + """`model_name` mirrors `model` so usage callbacks work on the Responses route.""" + result = _convert_responses_to_chat_result(_stub_responses_response("hi")) + meta = result.generations[0].message.response_metadata + assert meta["model_name"] == "sonar-pro" + assert meta["model"] == "sonar-pro" + + +def test_responses_completed_event_sets_model_name() -> None: + """Streaming `response.completed` also carries `model_name` for usage callbacks.""" + event = _make_event("response.completed", response=_stub_responses_response("x")) + chunk = _convert_responses_stream_event_to_chunk(event) + assert chunk is not None + assert chunk.message.response_metadata["model_name"] == "sonar-pro" diff --git a/libs/partners/perplexity/uv.lock b/libs/partners/perplexity/uv.lock index 9a0a358b842..99b6861d088 100644 --- a/libs/partners/perplexity/uv.lock +++ b/libs/partners/perplexity/uv.lock @@ -434,7 +434,7 @@ wheels = [ [[package]] name = "langchain-core" -version = "1.4.2" +version = "1.4.3" source = { editable = "../../core" } dependencies = [ { name = "jsonpatch" },