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:
rbuchmayer-pplx
2026-06-09 14:14:50 -07:00
committed by GitHub
parent fac194b34f
commit de9502525a
5 changed files with 607 additions and 16 deletions

View File

@@ -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,

View File

@@ -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)

View File

@@ -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

View File

@@ -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"

View File

@@ -434,7 +434,7 @@ wheels = [
[[package]]
name = "langchain-core"
version = "1.4.2"
version = "1.4.3"
source = { editable = "../../core" }
dependencies = [
{ name = "jsonpatch" },