Overuse may indicate design issues; consider polymorphism
return isinstance(obj, type) and is_basemodel_subclass(obj)
1"""Wrapper around Perplexity APIs."""23from __future__ import annotations45import json6import logging7from collections.abc import AsyncIterator, Callable, Iterator, Mapping, Sequence8from operator import itemgetter9from typing import Any, Literal, TypeAlias, cast1011from langchain_core.callbacks import (12 AsyncCallbackManagerForLLMRun,13 CallbackManagerForLLMRun,14)15from langchain_core.language_models import (16 LanguageModelInput,17 ModelProfile,18 ModelProfileRegistry,19)20from langchain_core.language_models.chat_models import (21 BaseChatModel,22 agenerate_from_stream,23 generate_from_stream,24)25from langchain_core.messages import (26 AIMessage,27 AIMessageChunk,28 BaseMessage,29 BaseMessageChunk,30 ChatMessage,31 ChatMessageChunk,32 FunctionMessageChunk,33 HumanMessage,34 HumanMessageChunk,35 SystemMessage,36 SystemMessageChunk,37 ToolMessage,38 ToolMessageChunk,39)40from langchain_core.messages.ai import (41 OutputTokenDetails,42 UsageMetadata,43 subtract_usage,44)45from langchain_core.messages.tool import tool_call_chunk46from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult47from langchain_core.runnables import Runnable, RunnableMap, RunnablePassthrough48from langchain_core.tools import BaseTool49from langchain_core.utils import get_pydantic_field_names, secret_from_env50from langchain_core.utils.function_calling import (51 convert_to_json_schema,52 convert_to_openai_tool,53)54from langchain_core.utils.pydantic import is_basemodel_subclass55from perplexity import AsyncPerplexity, Perplexity56from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator57from typing_extensions import Self5859from langchain_perplexity._version import __version__60from langchain_perplexity.data._profiles import _PROFILES61from langchain_perplexity.output_parsers import (62 ReasoningJsonOutputParser,63 ReasoningStructuredOutputParser,64)65from langchain_perplexity.types import MediaResponse, WebSearchOptions6667_DictOrPydanticClass: TypeAlias = dict[str, Any] | type[BaseModel]68_DictOrPydantic: TypeAlias = dict | BaseModel6970logger = logging.getLogger(__name__)717273_MODEL_PROFILES = cast("ModelProfileRegistry", _PROFILES)747576def _get_default_model_profile(model_name: str) -> ModelProfile:77 default = _MODEL_PROFILES.get(model_name) or {}78 return default.copy()798081def _is_pydantic_class(obj: Any) -> bool:82 return isinstance(obj, type) and is_basemodel_subclass(obj)838485def _create_usage_metadata(token_usage: dict) -> UsageMetadata:86 """Create UsageMetadata from Perplexity token usage data.8788 Args:89 token_usage: Dictionary containing token usage information from Perplexity API.9091 Returns:92 UsageMetadata with properly structured token counts and details.93 """94 input_tokens = token_usage.get("prompt_tokens", 0)95 output_tokens = token_usage.get("completion_tokens", 0)96 total_tokens = token_usage.get("total_tokens", input_tokens + output_tokens)9798 # Build output_token_details for Perplexity-specific fields99 output_token_details: OutputTokenDetails = {}100 if (reasoning := token_usage.get("reasoning_tokens")) is not None:101 output_token_details["reasoning"] = reasoning102 if (citation_tokens := token_usage.get("citation_tokens")) is not None:103 output_token_details["citation_tokens"] = citation_tokens # type: ignore[typeddict-unknown-key]104105 return UsageMetadata(106 input_tokens=input_tokens,107 output_tokens=output_tokens,108 total_tokens=total_tokens,109 output_token_details=output_token_details,110 )111112113_RESPONSES_ONLY_ARGS = frozenset(114 {"include", "input", "instructions", "previous_response_id"}115)116"""Top-level keys that exist only on Perplexity's Agent (Responses) API.117118The presence of any of these triggers auto-routing through Responses, since119the Chat Completions endpoint would silently reject them.120"""121122_RESPONSES_PASSTHROUGH_KEYS = frozenset(123 {124 "model",125 "models",126 "tools",127 "instructions",128 "language_preference",129 "max_steps",130 "preset",131 "reasoning",132 "response_format",133 "stream",134 "extra_body",135 "extra_headers",136 "extra_query",137 "timeout",138 }139)140"""Keys the Perplexity Responses SDK accepts natively.141142Mirrors `perplexity.resources.responses.ResponsesResource.create`. Anything143outside this set (other than known renames and drops) is routed through144`extra_body` so the SDK forwards it without breaking strict typing.145"""146147_RESPONSES_DROP_KEYS = frozenset({"temperature", "top_p", "top_k", "stop", "metadata"})148"""Chat-Completions-only sampling/control knobs the Responses (Agent) API does149not accept.150151Forwarding them would raise `TypeError` from the typed SDK signature in152`perplexity.resources.responses.ResponsesResource.create`, so they are dropped153at the boundary. Every drop emits a `WARNING`-level log on each call, except154the class-default `temperature`, which is suppressed because `_default_params`155injects `self.temperature` on every call regardless of user intent. A156user-supplied `temperature` (via init, `invoke(temperature=...)`, or `.bind`)157still warns.158159`tool_choice` is *not* in this set: it is a control-flow primitive160(forced/required tool selection) and is rejected with `ValueError` rather than161silently dropped, since downstream agent loops cannot recover.162"""163164165def _is_builtin_tool(tool: dict) -> bool:166 """Return True if `tool` is a Responses-API built-in (non-`function`) tool.167168 Perplexity's Agent API ships built-in tools (e.g. `web_search`,169 `code_interpreter`) that are identified by a `type` value other than170 `"function"`. Chat Completions only accepts function tools, so any tool171 failing this check forces the Responses route.172 """173 return "type" in tool and tool["type"] != "function"174175176def _flatten_responses_tool(tool: dict) -> dict:177 """Flatten a Chat-Completions function tool (nested under `function`) to178 the Responses-API's flat shape. Built-in tools (e.g. `web_search`) pass179 through unchanged.180 """181 if tool.get("type") == "function" and isinstance(tool.get("function"), dict):182 fn = tool["function"]183 flat: dict[str, Any] = {"type": "function", "name": fn.get("name")}184 for key in ("description", "parameters", "strict"):185 if key in fn:186 flat[key] = fn[key]187 return flat188 return tool189190191def _content_to_text(content: Any) -> str:192 """Concatenate text from a string or list-of-blocks content, dropping193 non-text blocks (e.g. a `tool_call`/`tool_use` block) that the Responses API194 can't take on a tool turn.195196 Only the optional plain-text preamble of an assistant tool turn is built197 here; the calls themselves are re-materialized as `function_call` items by198 `_translate_responses_input`, so nothing actionable is lost.199 """200 if isinstance(content, str):201 return content202 if isinstance(content, list):203 parts: list[str] = []204 for block in content:205 if isinstance(block, str):206 parts.append(block)207 elif isinstance(block, dict) and block.get("type") == "text":208 parts.append(block.get("text", ""))209 return "".join(parts)210 if content is not None:211 # An unexpected content shape (not str/list/None) is dropped rather than212 # guessed at; log it so content-shape drift stays diagnosable.213 logger.debug("Dropping unexpected content type %s on tool turn.", type(content))214 return ""215216217def _translate_responses_input(message_dicts: list[dict[str, Any]]) -> list[Any]:218 """Translate Chat-Completions message dicts into Responses-API input items.219220 The Responses API has no `tool` role: an assistant turn's `tool_calls`221 become `function_call` items and a `tool` message becomes a222 `function_call_output`. Other messages pass through.223224 `name`, `id`, and `tool_call_id` are the fields that pair a call with its225 result; `_convert_message_to_dict` always populates them, so a missing one226 here signals upstream drift or a hand-built message and is logged at227 `WARNING` rather than silently coerced.228 """229 translated: list[Any] = []230 for message in message_dicts:231 if not isinstance(message, dict):232 translated.append(message)233 continue234 role = message.get("role")235 if role == "assistant" and message.get("tool_calls"):236 # Assistant text (if any) becomes a plain message; the calls follow237 # as `function_call` items.238 text = _content_to_text(message.get("content"))239 if text:240 translated.append({"role": "assistant", "content": text})241 for tool_call in message["tool_calls"]:242 function = tool_call.get("function", {})243 call_id = tool_call.get("id")244 name = function.get("name", "")245 if not name or not call_id:246 logger.warning(247 "Assistant tool_call missing identity field "248 "(name=%r, id=%r); the Responses API may reject this "249 "turn or fail to pair the call with its output.",250 name,251 call_id,252 )253 translated.append(254 {255 "type": "function_call",256 "call_id": call_id,257 "name": name,258 "arguments": function.get("arguments", "") or "",259 }260 )261 elif role == "tool":262 content = message.get("content", "")263 output = content if isinstance(content, str) else json.dumps(content)264 call_id = message.get("tool_call_id")265 if not call_id:266 logger.warning(267 "Tool message missing tool_call_id; the Responses API "268 "cannot pair this function_call_output with its call."269 )270 translated.append(271 {272 "type": "function_call_output",273 "call_id": call_id,274 "output": output,275 }276 )277 else:278 translated.append(message)279 return translated280281282def _use_responses_api(payload: dict) -> bool:283 """Determine whether to route a payload through the Responses API.284285 The Agent (Responses) API is required for built-in tools and accepts286 fields that Chat Completions would reject — so callers must be routed287 there transparently when those signals appear.288289 Returns True if the payload contains a built-in tool (any element of290 `tools` whose `type` is not `"function"`) or any Responses-only field291 (`input`, `include`, `instructions`, `previous_response_id`).292 """293 uses_builtin_tools = "tools" in payload and any(294 _is_builtin_tool(tool) for tool in payload["tools"]295 )296 matched_fields = _RESPONSES_ONLY_ARGS.intersection(payload)297 if uses_builtin_tools or matched_fields:298 reason = (299 "payload contains a built-in tool (Chat Completions accepts only "300 "function tools)"301 if uses_builtin_tools302 else (303 f"payload sets Responses-only field(s) {sorted(matched_fields)} "304 "(Chat Completions would reject these)"305 )306 )307 logger.debug(308 "Routing through Perplexity Responses API: %s. "309 "Set use_responses_api=False to force Chat Completions.",310 reason,311 )312 return True313 return False314315316def _set_model_name_alias(response_metadata: dict[str, Any]) -> None:317 """Mirror `model` into `model_name`, which langchain-core usage callbacks318 read for cost tracking (the Chat Completions path already sets it).319 """320 if "model" in response_metadata:321 response_metadata["model_name"] = response_metadata["model"]322323324def _get_attr(obj: Any, name: str, default: Any = None) -> Any:325 """Safely fetch an attribute from an SDK object or a dict.326327 Responses SDK payloads arrive either as Pydantic-like SDK objects (server328 responses) or as plain dicts (when callers pass payloads pre-serialized or329 in tests). This helper normalizes both shapes so the rest of the module330 does not have to special-case them.331 """332 if isinstance(obj, dict):333 return obj.get(name, default)334 return getattr(obj, name, default)335336337def _convert_responses_usage(usage: Any) -> UsageMetadata | None:338 """Build `UsageMetadata` from a Responses API usage payload.339340 Returns `None` if `usage` itself is missing or if either token field is341 absent — emitting zeroed `UsageMetadata` would silently undercount usage342 in downstream cost dashboards.343 """344 if usage is None:345 return None346 input_tokens = _get_attr(usage, "input_tokens", None)347 output_tokens = _get_attr(usage, "output_tokens", None)348 if input_tokens is None or output_tokens is None:349 return None350 total_tokens = _get_attr(usage, "total_tokens", None)351 if total_tokens is None:352 total_tokens = input_tokens + output_tokens353 return UsageMetadata(354 input_tokens=input_tokens,355 output_tokens=output_tokens,356 total_tokens=total_tokens,357 )358359360def _extract_responses_text(response: Any) -> str:361 """Extract assistant text content from a Responses API response.362363 Prefers `response.output_text`, otherwise walks `output[*].content[*].text`.364 """365 text = _get_attr(response, "output_text", None)366 if isinstance(text, str) and text:367 return text368 output = _get_attr(response, "output", None) or []369 parts: list[str] = []370 for item in output:371 item_type = _get_attr(item, "type", None)372 if item_type and item_type != "message":373 continue374 content_blocks = _get_attr(item, "content", None) or []375 for block in content_blocks:376 block_text = _get_attr(block, "text", None)377 if isinstance(block_text, str):378 parts.append(block_text)379 return "".join(parts)380381382def _convert_responses_to_chat_result(response: Any) -> ChatResult:383 """Convert a Responses API response object to a `ChatResult`.384385 Maps `output_text`/`output[*].content[*].text` to `AIMessage.content` and386 surfaces `function_call` items as `tool_calls`. Perplexity-specific fields387 (`citations`, `images`, `related_questions`, `search_results`, `videos`,388 `reasoning_steps`) are placed on `additional_kwargs` to match the shape389 produced by the Chat Completions branch, while transport-level fields390 (`id`, `model`, `status`, `object`) land on `response_metadata`.391 """392 content = _extract_responses_text(response)393394 tool_calls: list[dict[str, Any]] = []395 output = _get_attr(response, "output", None) or []396 for item in output:397 item_type = _get_attr(item, "type", None)398 if item_type == "function_call":399 raw_args = _get_attr(item, "arguments", "") or ""400 try:401 parsed_args = json.loads(raw_args) if raw_args else {}402 except (TypeError, ValueError):403 logger.warning(404 "Failed to parse Perplexity function_call arguments as JSON "405 "for tool %r; preserving raw payload under __raw_arguments__.",406 _get_attr(item, "name", ""),407 exc_info=True,408 )409 parsed_args = {"__raw_arguments__": raw_args}410 tool_calls.append(411 {412 "name": _get_attr(item, "name", ""),413 "args": parsed_args,414 "id": _get_attr(item, "call_id", None)415 or _get_attr(item, "id", None),416 "type": "tool_call",417 }418 )419 elif item_type and item_type != "message":420 logger.debug("Ignoring unhandled Responses output item type: %s", item_type)421422 usage_metadata = _convert_responses_usage(_get_attr(response, "usage", None))423424 additional_kwargs: dict[str, Any] = {}425 for key in (426 "citations",427 "images",428 "related_questions",429 "search_results",430 "videos",431 "reasoning_steps",432 ):433 value = _get_attr(response, key, None)434 if value:435 additional_kwargs[key] = value436437 response_metadata: dict[str, Any] = {}438 for key in ("id", "model", "status", "object"):439 value = _get_attr(response, key, None)440 if value is not None:441 response_metadata[key] = value442 _set_model_name_alias(response_metadata)443444 message = AIMessage(445 content=content,446 additional_kwargs=additional_kwargs,447 tool_calls=tool_calls, # type: ignore[arg-type]448 usage_metadata=usage_metadata,449 response_metadata=response_metadata,450 )451 return ChatResult(generations=[ChatGeneration(message=message)])452453454class PerplexityResponsesStreamError(RuntimeError):455 """Raised when a Perplexity Responses (Agent) API stream fails mid-flight.456457 Carries the structured error fields the API surfaces (`code`, `type`,458 `param`, `request_id`) and the original event payload so observability459 pipelines can inspect them programmatically instead of regex-parsing the460 message string.461 """462463 def __init__(464 self,465 message: str,466 *,467 code: str | None = None,468 error_type: str | None = None,469 param: str | None = None,470 request_id: str | None = None,471 raw_event: Any = None,472 ) -> None:473 super().__init__(message)474 self.code = code475 self.error_type = error_type476 self.param = param477 self.request_id = request_id478 self.raw_event = raw_event479480481def _convert_responses_stream_event_to_chunk(482 event: Any,483) -> ChatGenerationChunk | None:484 """Convert a Responses API streaming event to a `ChatGenerationChunk`.485486 Handles `response.output_text.delta` (text chunk), `response.output_item.done`487 carrying a `function_call` (surfaced as a tool-call chunk), `response.completed`488 (final usage + metadata), and `response.failed` / `response.error`489 (raises `PerplexityResponsesStreamError`). Returns `None` for any other490 event type; unrecognized event types are logged at `DEBUG` so SDK drift is491 diagnosable without flooding logs.492 """493 event_type = _get_attr(event, "type", None)494 if event_type == "response.output_text.delta":495 delta = _get_attr(event, "delta", "") or ""496 return ChatGenerationChunk(message=AIMessageChunk(content=delta))497 if event_type == "response.output_item.done":498 item = _get_attr(event, "item", None)499 if item is not None and _get_attr(item, "type", None) == "function_call":500 # The Responses API delivers the whole function call in one item501 # (no argument deltas), so emit it as a single tool-call chunk.502 return ChatGenerationChunk(503 message=AIMessageChunk(504 content="",505 tool_call_chunks=[506 tool_call_chunk(507 name=_get_attr(item, "name", None),508 args=_get_attr(item, "arguments", None),509 id=_get_attr(item, "call_id", None)510 or _get_attr(item, "id", None),511 index=_get_attr(event, "output_index", 0),512 )513 ],514 )515 )516 return None517 if event_type == "response.completed":518 response = _get_attr(event, "response", None)519 usage_metadata = _convert_responses_usage(_get_attr(response, "usage", None))520 response_metadata: dict[str, Any] = {}521 additional_kwargs: dict[str, Any] = {}522 if response is not None:523 for key in ("id", "model", "status", "object"):524 value = _get_attr(response, key, None)525 if value is not None:526 response_metadata[key] = value527 _set_model_name_alias(response_metadata)528 for key in (529 "citations",530 "images",531 "related_questions",532 "search_results",533 "videos",534 "reasoning_steps",535 ):536 value = _get_attr(response, key, None)537 if value:538 additional_kwargs[key] = value539 return ChatGenerationChunk(540 message=AIMessageChunk(541 content="",542 additional_kwargs=additional_kwargs,543 usage_metadata=usage_metadata,544 response_metadata=response_metadata,545 )546 )547 if event_type in ("response.failed", "response.error"):548 # `response.failed` is the canonical SDK event name; `response.error`549 # is kept as a fallback in case the API surfaces it during transport.550 # Without this branch, a server-side failure mid-stream would yield551 # zero chunks and surface as "No generation chunks were returned"552 # from `BaseChatModel.stream`, obscuring the real error.553 error = _get_attr(event, "error", None)554 message = (555 _get_attr(error, "message", None)556 if error is not None557 else _get_attr(event, "message", None)558 ) or "Perplexity Responses API stream error"559 code = _get_attr(error, "code", None) if error is not None else None560 error_type = _get_attr(error, "type", None) if error is not None else None561 param = _get_attr(error, "param", None) if error is not None else None562 request_id = _get_attr(event, "request_id", None)563 details: list[str] = []564 for label, value in (565 ("code", code),566 ("type", error_type),567 ("param", param),568 ("request_id", request_id),569 ):570 if value is not None:571 details.append(f"{label}={value}")572 if details:573 message = f"{message} ({', '.join(details)})"574 logger.error(575 "Perplexity Responses stream failure: %s",576 message,577 extra={578 "perplexity_error_code": code,579 "perplexity_error_type": error_type,580 "perplexity_error_param": param,581 "perplexity_request_id": request_id,582 },583 )584 raise PerplexityResponsesStreamError(585 message,586 code=code,587 error_type=error_type,588 param=param,589 request_id=request_id,590 raw_event=event,591 )592 logger.debug("Ignoring unhandled Perplexity stream event type: %s", event_type)593 return None594595596class ChatPerplexity(BaseChatModel):597 """`Perplexity AI` Chat models API.598599 Setup:600 To use, you should have the environment variable `PPLX_API_KEY` set to your API key.601 Any parameters that are valid to be passed to the perplexity.create call602 can be passed in, even if not explicitly saved on this class.603604 ```bash605 export PPLX_API_KEY=your_api_key606 ```607608 Key init args - completion params:609 model:610 Name of the model to use. e.g. "sonar"611 temperature:612 Sampling temperature to use.613 max_tokens:614 Maximum number of tokens to generate.615 streaming:616 Whether to stream the results or not.617618 Key init args - client params:619 pplx_api_key:620 API key for PerplexityChat API.621 request_timeout:622 Timeout for requests to PerplexityChat completion API.623 max_retries:624 Maximum number of retries to make when generating.625626 See full list of supported init args and their descriptions in the params section.627628 Instantiate:629630 ```python631 from langchain_perplexity import ChatPerplexity632633 model = ChatPerplexity(model="sonar", temperature=0.7)634 ```635636 Invoke:637638 ```python639 messages = [("system", "You are a chatbot."), ("user", "Hello!")]640 model.invoke(messages)641 ```642643 Invoke with structured output:644645 ```python646 from pydantic import BaseModel647648649 class StructuredOutput(BaseModel):650 role: str651 content: str652653654 model.with_structured_output(StructuredOutput)655 model.invoke(messages)656 ```657658 Stream:659 ```python660 for chunk in model.stream(messages):661 print(chunk.content)662 ```663664 Token usage:665 ```python666 response = model.invoke(messages)667 response.usage_metadata668 ```669670 Response metadata:671 ```python672 response = model.invoke(messages)673 response.response_metadata674 ```675676 Agent API (Responses):677678 Set `use_responses_api=True` to route requests through Perplexity's Agent679 API (the Perplexity-flavored Responses API), or leave it unset to have it680 auto-detected when a built-in tool (e.g. `web_search`) or any681 Responses-only field (`previous_response_id`, `instructions`, `input`,682 `include`) is supplied.683684 ```python685 from langchain_perplexity import ChatPerplexity686687 model = ChatPerplexity(model="sonar-pro", use_responses_api=True)688 model.invoke("What is the capital of France?")689 ```690691 Auto-detection example:692693 ```python694 model = ChatPerplexity(model="sonar-pro")695 model.invoke(696 "Find recent news about AI.",697 tools=[{"type": "web_search"}],698 )699 ```700 """ # noqa: E501701702 client: Any = Field(default=None, exclude=True)703 async_client: Any = Field(default=None, exclude=True)704705 model: str = "sonar"706 """Model name."""707708 temperature: float = 0.7709 """What sampling temperature to use."""710711 model_kwargs: dict[str, Any] = Field(default_factory=dict)712 """Holds any model parameters valid for `create` call not explicitly specified."""713714 pplx_api_key: SecretStr | None = Field(715 default_factory=secret_from_env("PPLX_API_KEY", default=None), alias="api_key"716 )717 """Perplexity API key."""718719 request_timeout: float | tuple[float, float] | None = Field(None, alias="timeout")720 """Timeout for requests to PerplexityChat completion API."""721722 max_retries: int = 6723 """Maximum number of retries to make when generating."""724725 streaming: bool = False726 """Whether to stream the results or not."""727728 max_tokens: int | None = None729 """Maximum number of tokens to generate."""730731 use_responses_api: bool | None = None732 """Whether to use the Responses (Agent) API instead of the Chat Completions API.733734 If not specified then will be inferred based on invocation params. Specifically,735 requests will be routed to the Responses API when the payload includes a built-in736 tool (any `tools[*]` whose `type` is not `"function"`) or any of the737 Responses-only fields: `previous_response_id`, `instructions`, `input`, `include`.738739 Set explicitly to `True` to always use the Responses API, or `False` to always740 use Chat Completions.741742 !!! warning "Disabled parameters on the Responses (Agent) API"743744 The Perplexity Agent API does not accept Chat-Completions-only knobs.745 When routing through Responses (whether explicitly or by inference):746747 - `temperature`, `top_p`, `top_k`, `stop`, and `metadata` are dropped748 at the boundary with a `WARNING` log so the behavior change is749 discoverable. The class default `temperature` is dropped silently750 (it would otherwise spam every call), but a user-supplied751 `temperature` (init, `invoke(temperature=...)`, or `.bind`) still752 warns.753 - `tool_choice` raises `ValueError` rather than being dropped, since754 downstream agent loops cannot recover from a silently-disabled755 forced tool call.756 - Supplying a `preset` causes `model` to be dropped because the Agent757 API rejects bare Chat-Completions model names when `model` is758 provided. If `model` was explicitly set by the user, a `WARNING` is759 logged so the override is discoverable.760761 Use `use_responses_api=False` if you need any of these parameters to762 take effect.763 """764765 search_mode: Literal["academic", "sec", "web"] | None = None766 """Search mode for specialized content: "academic", "sec", or "web"."""767768 reasoning_effort: Literal["low", "medium", "high"] | None = None769 """Reasoning effort: "low", "medium", or "high" (default)."""770771 language_preference: str | None = None772 """Language preference:"""773774 search_domain_filter: list[str] | None = None775 """Search domain filter: list of domains to filter search results (max 20)."""776777 return_images: bool = False778 """Whether to return images in the response."""779780 return_related_questions: bool = False781 """Whether to return related questions in the response."""782783 search_recency_filter: Literal["day", "week", "month", "year"] | None = None784 """Filter search results by recency: "day", "week", "month", or "year"."""785786 search_after_date_filter: str | None = None787 """Search after date filter: date in format "MM/DD/YYYY" (default)."""788789 search_before_date_filter: str | None = None790 """Only return results before this date (format: MM/DD/YYYY)."""791792 last_updated_after_filter: str | None = None793 """Only return results updated after this date (format: MM/DD/YYYY)."""794795 last_updated_before_filter: str | None = None796 """Only return results updated before this date (format: MM/DD/YYYY)."""797798 disable_search: bool = False799 """Whether to disable web search entirely."""800801 enable_search_classifier: bool = False802 """Whether to enable the search classifier."""803804 web_search_options: WebSearchOptions | None = None805 """Configuration for web search behavior including Pro Search."""806807 media_response: MediaResponse | None = None808 """Media response: "images", "videos", or "none" (default)."""809810 model_config = ConfigDict(populate_by_name=True)811812 @property813 def lc_secrets(self) -> dict[str, str]:814 return {"pplx_api_key": "PPLX_API_KEY"}815816 @model_validator(mode="before")817 @classmethod818 def build_extra(cls, values: dict[str, Any]) -> Any:819 """Build extra kwargs from additional params that were passed in."""820 all_required_field_names = get_pydantic_field_names(cls)821 extra = values.get("model_kwargs", {})822 for field_name in list(values):823 if field_name in extra:824 raise ValueError(f"Found {field_name} supplied twice.")825 if field_name not in all_required_field_names:826 logger.warning(827 f"""WARNING! {field_name} is not a default parameter.828 {field_name} was transferred to model_kwargs.829 Please confirm that {field_name} is what you intended."""830 )831 extra[field_name] = values.pop(field_name)832833 invalid_model_kwargs = all_required_field_names.intersection(extra.keys())834 if invalid_model_kwargs:835 raise ValueError(836 f"Parameters {invalid_model_kwargs} should be specified explicitly. "837 f"Instead they were passed in as part of `model_kwargs` parameter."838 )839840 values["model_kwargs"] = extra841 return values842843 @model_validator(mode="after")844 def _set_perplexity_version(self) -> Self:845 """Set package version in metadata."""846 self._add_version("langchain-perplexity", __version__)847 return self848849 @model_validator(mode="after")850 def validate_environment(self) -> Self:851 """Validate that api key and python package exists in environment."""852 pplx_api_key = (853 self.pplx_api_key.get_secret_value() if self.pplx_api_key else None854 )855856 client_params: dict[str, Any] = {857 "api_key": pplx_api_key,858 "max_retries": self.max_retries,859 }860 if self.request_timeout is not None:861 client_params["timeout"] = self.request_timeout862863 if not self.client:864 self.client = Perplexity(**client_params)865866 if not self.async_client:867 self.async_client = AsyncPerplexity(**client_params)868869 return self870871 def _resolve_model_profile(self) -> ModelProfile | None:872 return _get_default_model_profile(self.model) or None873874 @property875 def _default_params(self) -> dict[str, Any]:876 """Get the default parameters for calling PerplexityChat API."""877 params: dict[str, Any] = {878 "max_tokens": self.max_tokens,879 "stream": self.streaming,880 "temperature": self.temperature,881 }882 if self.search_mode:883 params["search_mode"] = self.search_mode884 if self.reasoning_effort:885 params["reasoning_effort"] = self.reasoning_effort886 if self.language_preference:887 params["language_preference"] = self.language_preference888 if self.search_domain_filter:889 params["search_domain_filter"] = self.search_domain_filter890 if self.return_images:891 params["return_images"] = self.return_images892 if self.return_related_questions:893 params["return_related_questions"] = self.return_related_questions894 if self.search_recency_filter:895 params["search_recency_filter"] = self.search_recency_filter896 if self.search_after_date_filter:897 params["search_after_date_filter"] = self.search_after_date_filter898 if self.search_before_date_filter:899 params["search_before_date_filter"] = self.search_before_date_filter900 if self.last_updated_after_filter:901 params["last_updated_after_filter"] = self.last_updated_after_filter902 if self.last_updated_before_filter:903 params["last_updated_before_filter"] = self.last_updated_before_filter904 if self.disable_search:905 params["disable_search"] = self.disable_search906 if self.enable_search_classifier:907 params["enable_search_classifier"] = self.enable_search_classifier908 if self.web_search_options:909 params["web_search_options"] = self.web_search_options.model_dump(910 exclude_none=True911 )912 if self.media_response:913 if "extra_body" not in params:914 params["extra_body"] = {}915 params["extra_body"]["media_response"] = self.media_response.model_dump(916 exclude_none=True917 )918919 return {**params, **self.model_kwargs}920921 def _convert_message_to_dict(self, message: BaseMessage) -> dict[str, Any]:922 message_dict: dict[str, Any]923 if isinstance(message, ChatMessage):924 message_dict = {"role": message.role, "content": message.content}925 elif isinstance(message, SystemMessage):926 message_dict = {"role": "system", "content": message.content}927 elif isinstance(message, HumanMessage):928 message_dict = {"role": "user", "content": message.content}929 elif isinstance(message, AIMessage):930 message_dict = {"role": "assistant", "content": message.content}931 if message.tool_calls or message.invalid_tool_calls:932 message_dict["tool_calls"] = [933 {934 "id": tool_call["id"],935 "type": "function",936 "function": {937 "name": tool_call["name"],938 "arguments": json.dumps(939 tool_call["args"], ensure_ascii=False940 ),941 },942 }943 for tool_call in message.tool_calls944 ] + [945 {946 "id": tool_call["id"],947 "type": "function",948 "function": {949 "name": tool_call["name"],950 "arguments": tool_call["args"],951 },952 }953 for tool_call in message.invalid_tool_calls954 ]955 # OpenAI-compatible APIs reject empty-string content alongside956 # tool_calls; send null instead.957 message_dict["content"] = message_dict["content"] or None958 elif isinstance(message, ToolMessage):959 message_dict = {960 "role": "tool",961 "content": message.content,962 "tool_call_id": message.tool_call_id,963 }964 else:965 raise TypeError(f"Got unknown type {message}")966 return message_dict967968 def _create_message_dicts(969 self, messages: list[BaseMessage], stop: list[str] | None970 ) -> tuple[list[dict[str, Any]], dict[str, Any]]:971 params = dict(self._invocation_params)972 if stop is not None:973 if "stop" in params:974 raise ValueError("`stop` found in both the input and default params.")975 params["stop"] = stop976 message_dicts = [self._convert_message_to_dict(m) for m in messages]977 return message_dicts, params978979 def _use_responses_api(self, payload: dict) -> bool:980 """Return True if `payload` should be routed through the Responses API.981982 Honors `self.use_responses_api` when set explicitly; otherwise delegates983 to the module-level `_use_responses_api` heuristic.984 """985 if isinstance(self.use_responses_api, bool):986 return self.use_responses_api987 return _use_responses_api(payload)988989 def _to_responses_payload(990 self,991 message_dicts: list[dict[str, Any]],992 params: dict[str, Any],993 *,994 user_set_keys: set[str] | None = None,995 ) -> dict[str, Any]:996 """Translate a Chat Completions-style payload to the Responses API shape.997998 Renames `messages` to `input` and `max_tokens` to `max_output_tokens`.999 `None`-valued params are dropped. Chat-Completions-only sampling/control1000 parameters that the Perplexity Responses (Agent) API does not accept1001 (`temperature`, `top_p`, `top_k`, `stop`, `metadata`) are dropped at1002 the boundary because the typed SDK signature would otherwise raise a1003 `TypeError`; every drop emits a `WARNING`-level log on each call,1004 except the class-default `temperature`, which is suppressed because1005 `_default_params` injects it on every call regardless of user intent.10061007 `tool_choice` is rejected with `ValueError` rather than dropped: it is1008 a control-flow primitive (forced/required tool selection) that agent1009 loops depend on, so silently disabling it would produce wrong1010 completions while returning HTTP 200.10111012 When a `preset` is supplied, `model` is dropped — the Agent API1013 validates `model` strictly (it expects `provider/model` format), and1014 a preset selects routing/model behavior on its own. If the user1015 explicitly set `model` (init or via `kwargs`), a `WARNING` is logged1016 so the override is discoverable.10171018 Unknown or Perplexity-specific keys (including `previous_response_id`1019 and `include`, documented Perplexity features that the typed SDK1020 signature does not currently expose) are forwarded under `extra_body`.10211022 Args:1023 message_dicts: Chat messages already serialized to the Chat1024 Completions shape; promoted to `payload["input"]`.1025 params: Merged invocation params from `_default_params` and the1026 per-call `kwargs`.1027 user_set_keys: Keys the user explicitly supplied for this call1028 (typically `set(kwargs)`). Used in combination with1029 `self.model_fields_set` to distinguish class defaults from1030 explicit user intent for `temperature` and `model`.10311032 Raises:1033 ValueError: If `tool_choice` is supplied — the Responses API1034 cannot honor it.1035 TypeError: If a caller supplied an `extra_body` that is not a1036 `dict` — silently dropping subsequent params would mask1037 user-set search/filter knobs.1038 """1039 payload: dict[str, Any] = {"input": _translate_responses_input(message_dicts)}1040 runtime_keys = user_set_keys or set()1041 user_set_temperature = (1042 "temperature" in self.model_fields_set or "temperature" in runtime_keys1043 )1044 user_set_model = "model" in self.model_fields_set or "model" in runtime_keys1045 # Collect dropped values so the warning can name them.1046 dropped_for_warning: dict[str, Any] = {}1047 for key, value in params.items():1048 if value is None:1049 continue1050 if key == "messages":1051 continue1052 if key in _RESPONSES_DROP_KEYS:1053 # Suppress the warning for the class-default `temperature`,1054 # which `_default_params` injects on every call and would1055 # otherwise spam users who never asked for it.1056 if key != "temperature" or user_set_temperature:1057 dropped_for_warning[key] = value1058 continue1059 if key == "tool_choice":1060 msg = (1061 "Perplexity Responses (Agent) API does not support "1062 "`tool_choice`. Forced tool selection is unavailable on "1063 "this route. Set `use_responses_api=False` to use Chat "1064 "Completions, or remove `tool_choice` to let the model "1065 "decide."1066 )1067 raise ValueError(msg)1068 if key == "max_tokens":1069 payload["max_output_tokens"] = value1070 continue1071 if key == "tools":1072 # Function tools must be flattened to the Responses-API shape;1073 # built-in tools (web_search, etc.) pass through unchanged.1074 payload["tools"] = [_flatten_responses_tool(tool) for tool in value]1075 continue1076 if key in _RESPONSES_PASSTHROUGH_KEYS:1077 payload[key] = value1078 continue1079 # Unknown / Perplexity-specific keys: route under extra_body so the1080 # SDK forwards them to the Agent API without breaking strict typing.1081 extra_body = payload.setdefault("extra_body", {})1082 if not isinstance(extra_body, dict):1083 msg = (1084 "`extra_body` must be a dict to forward Perplexity-specific "1085 f"parameters to the Responses API, got "1086 f"{type(extra_body).__name__}={extra_body!r}; cannot merge "1087 f"user-set key {key!r}."1088 )1089 raise TypeError(msg)1090 extra_body[key] = value1091 # When the caller selected a preset, defer model selection to it: the1092 # Agent API rejects bare Chat-Completions model names like `sonar-pro`1093 # outright when `model` is set, even if a preset is also present.1094 if "preset" in payload:1095 dropped_model = payload.pop("model", None)1096 if user_set_model and dropped_model is not None:1097 logger.warning(1098 "Perplexity Agent API rejects `model` when `preset` is "1099 "set; dropping explicit model=%r in favor of preset=%r.",1100 dropped_model,1101 payload["preset"],1102 )1103 if dropped_for_warning:1104 logger.warning(1105 "Perplexity Responses (Agent) API does not accept %s; the "1106 "following values were dropped: %s. Use the Chat Completions "1107 "API (set `use_responses_api=False`) if you need them.",1108 sorted(dropped_for_warning),1109 dropped_for_warning,1110 )1111 return payload11121113 def _convert_delta_to_message_chunk(1114 self, _dict: Mapping[str, Any], default_class: type[BaseMessageChunk]1115 ) -> BaseMessageChunk:1116 role = _dict.get("role")1117 content = _dict.get("content") or ""1118 additional_kwargs: dict = {}1119 if _dict.get("function_call"):1120 function_call = dict(_dict["function_call"])1121 if "name" in function_call and function_call["name"] is None:1122 function_call["name"] = ""1123 additional_kwargs["function_call"] = function_call1124 if _dict.get("tool_calls"):1125 additional_kwargs["tool_calls"] = _dict["tool_calls"]11261127 if role == "user" or default_class == HumanMessageChunk:1128 return HumanMessageChunk(content=content)1129 elif role == "assistant" or default_class == AIMessageChunk:1130 return AIMessageChunk(content=content, additional_kwargs=additional_kwargs)1131 elif role == "system" or default_class == SystemMessageChunk:1132 return SystemMessageChunk(content=content)1133 elif role == "function" or default_class == FunctionMessageChunk:1134 return FunctionMessageChunk(content=content, name=_dict["name"])1135 elif role == "tool" or default_class == ToolMessageChunk:1136 return ToolMessageChunk(content=content, tool_call_id=_dict["tool_call_id"])1137 elif role or default_class == ChatMessageChunk:1138 return ChatMessageChunk(content=content, role=role) # type: ignore[arg-type]1139 else:1140 return default_class(content=content) # type: ignore[call-arg]11411142 def _stream(1143 self,1144 messages: list[BaseMessage],1145 stop: list[str] | None = None,1146 run_manager: CallbackManagerForLLMRun | None = None,1147 **kwargs: Any,1148 ) -> Iterator[ChatGenerationChunk]:1149 message_dicts, params = self._create_message_dicts(messages, stop)1150 runtime_keys = set(kwargs)1151 if stop is not None:1152 runtime_keys.add("stop")1153 params = {**params, **kwargs}1154 default_chunk_class = AIMessageChunk1155 params.pop("stream", None)1156 if self._use_responses_api({**params, "messages": message_dicts}):1157 responses_payload = self._to_responses_payload(1158 message_dicts, params, user_set_keys=runtime_keys1159 )1160 responses_payload["stream"] = True1161 stream_events = self.client.responses.create(**responses_payload)1162 # Trusts SDK SSE decoding (perplexityai>=0.34.1, upstream issue1163 # perplexityai-python#53). `_convert_responses_stream_event_to_chunk`1164 # already handles both SDK objects and dicts via `_get_attr`.1165 for event in stream_events:1166 response_chunk = _convert_responses_stream_event_to_chunk(event)1167 if response_chunk is None:1168 continue1169 if run_manager:1170 run_manager.on_llm_new_token(1171 response_chunk.text, chunk=response_chunk1172 )1173 yield response_chunk1174 return1175 if stop:1176 params["stop_sequences"] = stop1177 stream_resp = self.client.chat.completions.create(1178 messages=message_dicts, stream=True, **params1179 )1180 first_chunk = True1181 prev_total_usage: UsageMetadata | None = None11821183 added_model_name: bool = False1184 added_search_queries: bool = False1185 added_search_context_size: bool = False1186 for chunk in stream_resp:1187 if not isinstance(chunk, dict):1188 chunk = chunk.model_dump()1189 # Collect standard usage metadata (transform from aggregate to delta)1190 if total_usage := chunk.get("usage"):1191 lc_total_usage = _create_usage_metadata(total_usage)1192 if prev_total_usage:1193 usage_metadata: UsageMetadata | None = subtract_usage(1194 lc_total_usage, prev_total_usage1195 )1196 else:1197 usage_metadata = lc_total_usage1198 prev_total_usage = lc_total_usage1199 else:1200 usage_metadata = None1201 generation_info = {}1202 if (model_name := chunk.get("model")) and not added_model_name:1203 generation_info["model_name"] = model_name1204 added_model_name = True1205 if total_usage := chunk.get("usage"):1206 if num_search_queries := total_usage.get("num_search_queries"):1207 if not added_search_queries:1208 generation_info["num_search_queries"] = num_search_queries1209 added_search_queries = True1210 if not added_search_context_size:1211 if search_context_size := total_usage.get("search_context_size"):1212 generation_info["search_context_size"] = search_context_size1213 added_search_context_size = True12141215 choices = chunk.get("choices") or []1216 if len(choices) == 0:1217 # Usage-only or otherwise empty chunk: still yield so the stream1218 # is never empty and downstream callers receive usage metadata.1219 message = AIMessageChunk(content="", usage_metadata=usage_metadata)1220 yield ChatGenerationChunk(1221 message=message, generation_info=generation_info or None1222 )1223 continue1224 choice = choices[0]12251226 additional_kwargs = {}1227 if first_chunk:1228 additional_kwargs["citations"] = chunk.get("citations", [])1229 for attr in ["images", "related_questions", "search_results"]:1230 if attr in chunk:1231 additional_kwargs[attr] = chunk[attr]12321233 if chunk.get("videos"):1234 additional_kwargs["videos"] = chunk["videos"]12351236 if chunk.get("reasoning_steps"):1237 additional_kwargs["reasoning_steps"] = chunk["reasoning_steps"]12381239 chunk = self._convert_delta_to_message_chunk(1240 choice["delta"], default_chunk_class1241 )12421243 if isinstance(chunk, AIMessageChunk) and usage_metadata:1244 chunk.usage_metadata = usage_metadata12451246 if first_chunk:1247 chunk.additional_kwargs |= additional_kwargs1248 first_chunk = False12491250 if finish_reason := choice.get("finish_reason"):1251 generation_info["finish_reason"] = finish_reason12521253 default_chunk_class = chunk.__class__1254 chunk = ChatGenerationChunk(message=chunk, generation_info=generation_info)1255 if run_manager:1256 run_manager.on_llm_new_token(chunk.text, chunk=chunk)1257 yield chunk12581259 async def _astream(1260 self,1261 messages: list[BaseMessage],1262 stop: list[str] | None = None,1263 run_manager: AsyncCallbackManagerForLLMRun | None = None,1264 **kwargs: Any,1265 ) -> AsyncIterator[ChatGenerationChunk]:1266 message_dicts, params = self._create_message_dicts(messages, stop)1267 runtime_keys = set(kwargs)1268 if stop is not None:1269 runtime_keys.add("stop")1270 params = {**params, **kwargs}1271 default_chunk_class = AIMessageChunk1272 params.pop("stream", None)1273 if self._use_responses_api({**params, "messages": message_dicts}):1274 responses_payload = self._to_responses_payload(1275 message_dicts, params, user_set_keys=runtime_keys1276 )1277 responses_payload["stream"] = True1278 stream_events = await self.async_client.responses.create(1279 **responses_payload1280 )1281 # See sync `_stream` for SDK trust rationale (perplexityai>=0.34.1).1282 async for event in stream_events:1283 response_chunk = _convert_responses_stream_event_to_chunk(event)1284 if response_chunk is None:1285 continue1286 if run_manager:1287 await run_manager.on_llm_new_token(1288 response_chunk.text, chunk=response_chunk1289 )1290 yield response_chunk1291 return1292 if stop:1293 params["stop_sequences"] = stop1294 stream_resp = await self.async_client.chat.completions.create(1295 messages=message_dicts, stream=True, **params1296 )1297 first_chunk = True1298 prev_total_usage: UsageMetadata | None = None12991300 added_model_name: bool = False1301 added_search_queries: bool = False1302 async for chunk in stream_resp:1303 if not isinstance(chunk, dict):1304 chunk = chunk.model_dump()1305 if total_usage := chunk.get("usage"):1306 lc_total_usage = _create_usage_metadata(total_usage)1307 if prev_total_usage:1308 usage_metadata: UsageMetadata | None = subtract_usage(1309 lc_total_usage, prev_total_usage1310 )1311 else:1312 usage_metadata = lc_total_usage1313 prev_total_usage = lc_total_usage1314 else:1315 usage_metadata = None1316 generation_info = {}1317 if (model_name := chunk.get("model")) and not added_model_name:1318 generation_info["model_name"] = model_name1319 added_model_name = True1320 if total_usage := chunk.get("usage"):1321 if num_search_queries := total_usage.get("num_search_queries"):1322 if not added_search_queries:1323 generation_info["num_search_queries"] = num_search_queries1324 added_search_queries = True1325 if search_context_size := total_usage.get("search_context_size"):1326 generation_info["search_context_size"] = search_context_size13271328 choices = chunk.get("choices") or []1329 if len(choices) == 0:1330 # Usage-only or otherwise empty chunk: still yield so the stream1331 # is never empty and downstream callers receive usage metadata.1332 message = AIMessageChunk(content="", usage_metadata=usage_metadata)1333 yield ChatGenerationChunk(1334 message=message, generation_info=generation_info or None1335 )1336 continue1337 choice = choices[0]13381339 additional_kwargs = {}1340 if first_chunk:1341 additional_kwargs["citations"] = chunk.get("citations", [])1342 for attr in ["images", "related_questions", "search_results"]:1343 if attr in chunk:1344 additional_kwargs[attr] = chunk[attr]13451346 if chunk.get("videos"):1347 additional_kwargs["videos"] = chunk["videos"]13481349 if chunk.get("reasoning_steps"):1350 additional_kwargs["reasoning_steps"] = chunk["reasoning_steps"]13511352 chunk = self._convert_delta_to_message_chunk(1353 choice["delta"], default_chunk_class1354 )13551356 if isinstance(chunk, AIMessageChunk) and usage_metadata:1357 chunk.usage_metadata = usage_metadata13581359 if first_chunk:1360 chunk.additional_kwargs |= additional_kwargs1361 first_chunk = False13621363 if finish_reason := choice.get("finish_reason"):1364 generation_info["finish_reason"] = finish_reason13651366 default_chunk_class = chunk.__class__1367 chunk = ChatGenerationChunk(message=chunk, generation_info=generation_info)1368 if run_manager:1369 await run_manager.on_llm_new_token(chunk.text, chunk=chunk)1370 yield chunk13711372 def _generate(1373 self,1374 messages: list[BaseMessage],1375 stop: list[str] | None = None,1376 run_manager: CallbackManagerForLLMRun | None = None,1377 **kwargs: Any,1378 ) -> ChatResult:1379 if self.streaming:1380 stream_iter = self._stream(1381 messages, stop=stop, run_manager=run_manager, **kwargs1382 )1383 if stream_iter:1384 return generate_from_stream(stream_iter)1385 message_dicts, params = self._create_message_dicts(messages, stop)1386 runtime_keys = set(kwargs)1387 if stop is not None:1388 runtime_keys.add("stop")1389 params = {**params, **kwargs}1390 if self._use_responses_api({**params, "messages": message_dicts}):1391 responses_payload = self._to_responses_payload(1392 message_dicts, params, user_set_keys=runtime_keys1393 )1394 responses_payload.pop("stream", None)1395 response = self.client.responses.create(**responses_payload)1396 return _convert_responses_to_chat_result(response)1397 response = self.client.chat.completions.create(messages=message_dicts, **params)13981399 if hasattr(response, "usage") and response.usage:1400 usage_dict = response.usage.model_dump()1401 usage_metadata = _create_usage_metadata(usage_dict)1402 else:1403 usage_metadata = None1404 usage_dict = {}14051406 additional_kwargs = {}1407 for attr in ["citations", "images", "related_questions", "search_results"]:1408 if hasattr(response, attr) and getattr(response, attr):1409 additional_kwargs[attr] = getattr(response, attr)14101411 if hasattr(response, "videos") and response.videos:1412 additional_kwargs["videos"] = [1413 v.model_dump() if hasattr(v, "model_dump") else v1414 for v in response.videos1415 ]14161417 if hasattr(response, "reasoning_steps") and response.reasoning_steps:1418 additional_kwargs["reasoning_steps"] = [1419 r.model_dump() if hasattr(r, "model_dump") else r1420 for r in response.reasoning_steps1421 ]14221423 response_metadata: dict[str, Any] = {1424 "model_name": getattr(response, "model", self.model)1425 }1426 if num_search_queries := usage_dict.get("num_search_queries"):1427 response_metadata["num_search_queries"] = num_search_queries1428 if search_context_size := usage_dict.get("search_context_size"):1429 response_metadata["search_context_size"] = search_context_size14301431 message = AIMessage(1432 content=response.choices[0].message.content,1433 additional_kwargs=additional_kwargs,1434 usage_metadata=usage_metadata,1435 response_metadata=response_metadata,1436 )1437 return ChatResult(generations=[ChatGeneration(message=message)])14381439 async def _agenerate(1440 self,1441 messages: list[BaseMessage],1442 stop: list[str] | None = None,1443 run_manager: AsyncCallbackManagerForLLMRun | None = None,1444 **kwargs: Any,1445 ) -> ChatResult:1446 if self.streaming:1447 stream_iter = self._astream(1448 messages, stop=stop, run_manager=run_manager, **kwargs1449 )1450 if stream_iter:1451 return await agenerate_from_stream(stream_iter)1452 message_dicts, params = self._create_message_dicts(messages, stop)1453 runtime_keys = set(kwargs)1454 if stop is not None:1455 runtime_keys.add("stop")1456 params = {**params, **kwargs}1457 if self._use_responses_api({**params, "messages": message_dicts}):1458 responses_payload = self._to_responses_payload(1459 message_dicts, params, user_set_keys=runtime_keys1460 )1461 responses_payload.pop("stream", None)1462 response = await self.async_client.responses.create(**responses_payload)1463 return _convert_responses_to_chat_result(response)1464 response = await self.async_client.chat.completions.create(1465 messages=message_dicts, **params1466 )14671468 if hasattr(response, "usage") and response.usage:1469 usage_dict = response.usage.model_dump()1470 usage_metadata = _create_usage_metadata(usage_dict)1471 else:1472 usage_metadata = None1473 usage_dict = {}14741475 additional_kwargs = {}1476 for attr in ["citations", "images", "related_questions", "search_results"]:1477 if hasattr(response, attr) and getattr(response, attr):1478 additional_kwargs[attr] = getattr(response, attr)14791480 if hasattr(response, "videos") and response.videos:1481 additional_kwargs["videos"] = [1482 v.model_dump() if hasattr(v, "model_dump") else v1483 for v in response.videos1484 ]14851486 if hasattr(response, "reasoning_steps") and response.reasoning_steps:1487 additional_kwargs["reasoning_steps"] = [1488 r.model_dump() if hasattr(r, "model_dump") else r1489 for r in response.reasoning_steps1490 ]14911492 response_metadata: dict[str, Any] = {1493 "model_name": getattr(response, "model", self.model)1494 }1495 if num_search_queries := usage_dict.get("num_search_queries"):1496 response_metadata["num_search_queries"] = num_search_queries1497 if search_context_size := usage_dict.get("search_context_size"):1498 response_metadata["search_context_size"] = search_context_size14991500 message = AIMessage(1501 content=response.choices[0].message.content,1502 additional_kwargs=additional_kwargs,1503 usage_metadata=usage_metadata,1504 response_metadata=response_metadata,1505 )1506 return ChatResult(generations=[ChatGeneration(message=message)])15071508 @property1509 def _invocation_params(self) -> Mapping[str, Any]:1510 """Get the parameters used to invoke the model."""1511 pplx_creds: dict[str, Any] = {"model": self.model}1512 return {**pplx_creds, **self._default_params}15131514 @property1515 def _llm_type(self) -> str:1516 """Return type of chat model."""1517 return "perplexitychat"15181519 def bind_tools(1520 self,1521 tools: Sequence[dict[str, Any] | type | Callable | BaseTool],1522 *,1523 tool_choice: dict | str | bool | None = None,1524 strict: bool | None = None,1525 **kwargs: Any,1526 ) -> Runnable[LanguageModelInput, AIMessage]:1527 """Bind tool-like objects to this chat model.15281529 Client-side function tools require the Perplexity Responses (Agent) API:1530 construct the model with `use_responses_api=True` and a tool-capable1531 model such as `openai/gpt-5`. The `sonar` family does not support1532 client-side function tools.15331534 Args:1535 tools: A list of tool definitions to bind to this chat model.1536 Supports any tool handled by1537 [convert_to_openai_tool][langchain_core.utils.function_calling.convert_to_openai_tool]1538 (Pydantic models, `TypedDict` classes, callables, `BaseTool`,1539 or OpenAI-format dicts), as well as Perplexity built-in tools such1540 as `{"type": "web_search"}`, which are passed through unchanged.1541 tool_choice: Which tool the model should use. Normalized here for API1542 parity with `langchain-openai` (a tool name, `"auto"`, `"none"`,1543 `"any"`/`"required"`/`True`, or an OpenAI-style dict) and stored1544 on the binding, but the Perplexity Responses (Agent) API does not1545 currently honor it: a non-empty `tool_choice` makes1546 `_to_responses_payload` raise `ValueError` at invoke time on the1547 Responses route. The restriction can be relaxed if Perplexity1548 adds `tool_choice` support.1549 strict: If `True`, the tool parameter schema is sent with `strict`1550 enabled. If `None` (default), the flag is omitted.1551 kwargs: Any additional parameters are passed directly to `bind`.1552 """1553 formatted_tools = [1554 tool1555 if isinstance(tool, dict) and _is_builtin_tool(tool)1556 else convert_to_openai_tool(tool, strict=strict)1557 for tool in tools1558 ]1559 if tool_choice:1560 tool_names = [1561 t["function"]["name"] if "function" in t else t.get("name")1562 for t in formatted_tools1563 ]1564 if isinstance(tool_choice, str):1565 if tool_choice in tool_names:1566 tool_choice = {1567 "type": "function",1568 "function": {"name": tool_choice},1569 }1570 # 'any' is not native to the OpenAI schema; map it to 'required'1571 # for parity with providers that use 'any'.1572 elif tool_choice == "any":1573 tool_choice = "required"1574 elif isinstance(tool_choice, bool):1575 tool_choice = "required"1576 elif isinstance(tool_choice, dict):1577 pass1578 else:1579 msg = (1580 "Unrecognized tool_choice type. Expected str, bool or dict. "1581 f"Received: {tool_choice}"1582 )1583 raise ValueError(msg)1584 kwargs["tool_choice"] = tool_choice1585 return super().bind(tools=formatted_tools, **kwargs)15861587 def with_structured_output(1588 self,1589 schema: _DictOrPydanticClass | None = None,1590 *,1591 method: Literal["json_schema"] = "json_schema",1592 include_raw: bool = False,1593 strict: bool | None = None,1594 **kwargs: Any,1595 ) -> Runnable[LanguageModelInput, _DictOrPydantic]:1596 """Model wrapper that returns outputs formatted to match the given schema for Preplexity.1597 Currently, Perplexity only supports "json_schema" method for structured output1598 as per their [official documentation](https://docs.perplexity.ai/guides/structured-outputs).15991600 Args:1601 schema: The output schema. Can be passed in as:16021603 - a JSON Schema,1604 - a `TypedDict` class,1605 - or a Pydantic class16061607 method: The method for steering model generation, currently only support:16081609 - `'json_schema'`: Use the JSON Schema to parse the model output161016111612 include_raw:1613 If `False` then only the parsed structured output is returned.16141615 If an error occurs during model output parsing it will be raised.16161617 If `True` then both the raw model response (a `BaseMessage`) and the1618 parsed model response will be returned.16191620 If an error occurs during output parsing it will be caught and returned1621 as well.16221623 The final output is always a `dict` with keys `'raw'`, `'parsed'`, and1624 `'parsing_error'`.1625 strict:1626 Unsupported: whether to enable strict schema adherence when generating1627 the output. This parameter is included for compatibility with other1628 chat models, but is currently ignored.16291630 kwargs: Additional keyword args aren't supported.16311632 Returns:1633 A `Runnable` that takes same inputs as a1634 `langchain_core.language_models.chat.BaseChatModel`. If `include_raw` is1635 `False` and `schema` is a Pydantic class, `Runnable` outputs an instance1636 of `schema` (i.e., a Pydantic object). Otherwise, if `include_raw` is1637 `False` then `Runnable` outputs a `dict`.16381639 If `include_raw` is `True`, then `Runnable` outputs a `dict` with keys:16401641 - `'raw'`: `BaseMessage`1642 - `'parsed'`: `None` if there was a parsing error, otherwise the type1643 depends on the `schema` as described above.1644 - `'parsing_error'`: `BaseException | None`1645 """ # noqa: E5011646 if method in ("function_calling", "json_mode"):1647 method = "json_schema"1648 if method == "json_schema":1649 if schema is None:1650 raise ValueError(1651 "schema must be specified when method is not 'json_schema'. "1652 "Received None."1653 )1654 is_pydantic_schema = _is_pydantic_class(schema)1655 response_format = convert_to_json_schema(schema)1656 llm = self.bind(1657 response_format={1658 "type": "json_schema",1659 "json_schema": {"schema": response_format},1660 },1661 ls_structured_output_format={1662 "kwargs": {"method": method},1663 "schema": response_format,1664 },1665 )1666 output_parser = (1667 ReasoningStructuredOutputParser(pydantic_object=schema) # type: ignore[arg-type]1668 if is_pydantic_schema1669 else ReasoningJsonOutputParser()1670 )1671 else:1672 raise ValueError(1673 f"Unrecognized method argument. Expected 'json_schema' Received:\1674 '{method}'"1675 )16761677 if include_raw:1678 parser_assign = RunnablePassthrough.assign(1679 parsed=itemgetter("raw") | output_parser, parsing_error=lambda _: None1680 )1681 parser_none = RunnablePassthrough.assign(parsed=lambda _: None)1682 parser_with_fallback = parser_assign.with_fallbacks(1683 [parser_none], exception_key="parsing_error"1684 )1685 return RunnableMap(raw=llm) | parser_with_fallback1686 else:1687 return llm | output_parser
Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.