mirror of
https://github.com/hwchase17/langchain.git
synced 2026-07-01 14:47:02 +00:00
feat(perplexity): bind_tools and Responses-API tool round-trip (#37934)
## Summary Follow-up to #37911 (released in `langchain-perplexity` 1.3.2). That PR fixed the outbound `ToolMessage` / `AIMessage.tool_calls` serialization; this one implements **`ChatPerplexity.bind_tools`**, which flips `has_tool_calling` to `True` and lights up the full `langchain-tests` standard tool-calling suite — the suite that would have caught #37911 in the first place. Verified live against the Perplexity Agent API (`openai/gpt-5.5`, `use_responses_api=True`): a client-side function-tool round-trip (invoke + stream) works end-to-end. ## Core change (the `bind_tools` work + the Responses-API follow-up) - **`bind_tools`** mirrors `langchain-openai`: converts tools via `convert_to_openai_tool`, normalizes `tool_choice`, and passes Perplexity built-in tools (`web_search`, etc.) through unchanged. - **`_to_responses_payload`** now translates tool turns into the Responses (Agent) API's typed input items: `AIMessage.tool_calls` → `function_call`, `ToolMessage` → `function_call_output`, and flattens function tool specs. (The Responses API has no `tool` role, so this translation is required for round-trips.) ## Changes required to make standard-suite tests pass on the Responses route - Streaming: `_convert_responses_stream_event_to_chunk` emits a `tool_call_chunk` on `response.output_item.done` function calls — required by `test_tool_calling` (which streams and asserts tool calls). - `_content_to_text` reduces list-shaped assistant content to text in the tool-call branch — required by `test_agent_loop` and `test_tool_message_histories_list_content`. - `response_metadata["model_name"]` on the Responses route, mirroring Chat Completions — required by `test_usage_metadata` / `test_usage_metadata_streaming` (used by `langchain_core` usage callbacks). ## Tests - `sonar` standard class marked `has_tool_calling=False` (the family returns 400 "Tool calling is not supported for this model"). - New `TestPerplexityResponsesStandard` runs the full suite on `openai/gpt-5.5` + `use_responses_api` with `has_tool_choice=False`: **35 passed, 13 skipped, 2 xfailed**. - The 2 xfails (`test_unicode_tool_call_integration`, `test_structured_few_shot_examples`) hard-code `tool_choice="any"`. The Responses (Agent) API does not support `tool_choice` (verified: every form returns HTTP 200 without forcing a call), which `ChatPerplexity` surfaces as `ValueError` — **existing behavior, unchanged here.** Softening that to a warning can be a separate change. `make format lint` clean; unit + standard tests green. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Mason Daugherty <mason@langchain.dev> Co-authored-by: Mason Daugherty <github@mdrxy.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
2
libs/partners/perplexity/uv.lock
generated
2
libs/partners/perplexity/uv.lock
generated
@@ -434,7 +434,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.4.2"
|
||||
version = "1.4.3"
|
||||
source = { editable = "../../core" }
|
||||
dependencies = [
|
||||
{ name = "jsonpatch" },
|
||||
|
||||
Reference in New Issue
Block a user