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 InputTokenDetails,42 OutputTokenDetails,43 UsageMetadata,44 subtract_usage,45)46from langchain_core.messages.tool import tool_call_chunk47from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult48from langchain_core.runnables import Runnable, RunnableMap, RunnablePassthrough49from langchain_core.tools import BaseTool50from langchain_core.utils import get_pydantic_field_names, secret_from_env51from langchain_core.utils.function_calling import (52 convert_to_json_schema,53 convert_to_openai_tool,54)55from langchain_core.utils.pydantic import is_basemodel_subclass56from perplexity import AsyncPerplexity, Perplexity57from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator58from typing_extensions import Self5960from langchain_perplexity._version import __version__61from langchain_perplexity.data._profiles import _PROFILES62from langchain_perplexity.output_parsers import (63 ReasoningJsonOutputParser,64 ReasoningStructuredOutputParser,65)66from langchain_perplexity.types import MediaResponse, WebSearchOptions6768_DictOrPydanticClass: TypeAlias = dict[str, Any] | type[BaseModel]69_DictOrPydantic: TypeAlias = dict | BaseModel7071logger = logging.getLogger(__name__)727374_MODEL_PROFILES = cast("ModelProfileRegistry", _PROFILES)757677def _get_default_model_profile(model_name: str) -> ModelProfile:78 default = _MODEL_PROFILES.get(model_name) or {}79 return default.copy()808182def _is_pydantic_class(obj: Any) -> bool:83 return isinstance(obj, type) and is_basemodel_subclass(obj)848586def _create_usage_metadata(token_usage: dict) -> UsageMetadata:87 """Create UsageMetadata from Perplexity token usage data.8889 Args:90 token_usage: Dictionary containing token usage information from Perplexity API.9192 Returns:93 UsageMetadata with properly structured token counts and details.94 """95 input_tokens = token_usage.get("prompt_tokens", 0)96 output_tokens = token_usage.get("completion_tokens", 0)97 total_tokens = token_usage.get("total_tokens", input_tokens + output_tokens)9899 # Build output_token_details for Perplexity-specific fields100 output_token_details: OutputTokenDetails = {}101 if (reasoning := token_usage.get("reasoning_tokens")) is not None:102 output_token_details["reasoning"] = reasoning103 if (citation_tokens := token_usage.get("citation_tokens")) is not None:104 output_token_details["citation_tokens"] = citation_tokens # type: ignore[typeddict-unknown-key]105106 return UsageMetadata(107 input_tokens=input_tokens,108 output_tokens=output_tokens,109 total_tokens=total_tokens,110 output_token_details=output_token_details,111 )112113114_RESPONSES_ONLY_ARGS = frozenset(115 {"include", "input", "instructions", "previous_response_id"}116)117"""Top-level keys that exist only on Perplexity's Agent (Responses) API.118119The presence of any of these triggers auto-routing through Responses, since120the Chat Completions endpoint would silently reject them.121"""122123_RESPONSES_PASSTHROUGH_KEYS = frozenset(124 {125 "model",126 "models",127 "tools",128 "instructions",129 "language_preference",130 "max_steps",131 "preset",132 "reasoning",133 "response_format",134 "stream",135 "extra_body",136 "extra_headers",137 "extra_query",138 "timeout",139 }140)141"""Keys the Perplexity Responses SDK accepts natively.142143Mirrors `perplexity.resources.responses.ResponsesResource.create`. Anything144outside this set (other than known renames and drops) is routed through145`extra_body` so the SDK forwards it without breaking strict typing.146"""147148_RESPONSES_DROP_KEYS = frozenset({"temperature", "top_p", "top_k", "stop", "metadata"})149"""Chat-Completions-only sampling/control knobs the Responses (Agent) API does150not accept.151152Forwarding them would raise `TypeError` from the typed SDK signature in153`perplexity.resources.responses.ResponsesResource.create`, so they are dropped154at the boundary. Every drop emits a `WARNING`-level log on each call, except155the class-default `temperature`, which is suppressed because `_default_params`156injects `self.temperature` on every call regardless of user intent. A157user-supplied `temperature` (via init, `invoke(temperature=...)`, or `.bind`)158still warns.159160`tool_choice` is *not* in this set: it is a control-flow primitive161(forced/required tool selection) and is rejected with `ValueError` rather than162silently dropped, since downstream agent loops cannot recover.163"""164165166def _is_builtin_tool(tool: dict) -> bool:167 """Return True if `tool` is a Responses-API built-in (non-`function`) tool.168169 Perplexity's Agent API ships built-in tools (e.g. `web_search`,170 `code_interpreter`) that are identified by a `type` value other than171 `"function"`. Chat Completions only accepts function tools, so any tool172 failing this check forces the Responses route.173 """174 return "type" in tool and tool["type"] != "function"175176177def _flatten_responses_tool(tool: dict) -> dict:178 """Flatten a Chat-Completions function tool (nested under `function`) to179 the Responses-API's flat shape. Built-in tools (e.g. `web_search`) pass180 through unchanged.181 """182 if tool.get("type") == "function" and isinstance(tool.get("function"), dict):183 fn = tool["function"]184 flat: dict[str, Any] = {"type": "function", "name": fn.get("name")}185 for key in ("description", "parameters", "strict"):186 if key in fn:187 flat[key] = fn[key]188 return flat189 return tool190191192def _content_to_text(content: Any) -> str:193 """Concatenate text from a string or list-of-blocks content, dropping194 non-text blocks (e.g. a `tool_call`/`tool_use` block) that the Responses API195 can't take on a tool turn.196197 Only the optional plain-text preamble of an assistant tool turn is built198 here; the calls themselves are re-materialized as `function_call` items by199 `_translate_responses_input`, so nothing actionable is lost.200 """201 if isinstance(content, str):202 return content203 if isinstance(content, list):204 parts: list[str] = []205 for block in content:206 if isinstance(block, str):207 parts.append(block)208 elif isinstance(block, dict) and block.get("type") == "text":209 parts.append(block.get("text", ""))210 return "".join(parts)211 if content is not None:212 # An unexpected content shape (not str/list/None) is dropped rather than213 # guessed at; log it so content-shape drift stays diagnosable.214 logger.debug("Dropping unexpected content type %s on tool turn.", type(content))215 return ""216217218def _translate_responses_input(message_dicts: list[dict[str, Any]]) -> list[Any]:219 """Translate Chat-Completions message dicts into Responses-API input items.220221 The Responses API has no `tool` role: an assistant turn's `tool_calls`222 become `function_call` items and a `tool` message becomes a223 `function_call_output`. Other messages pass through.224225 `name`, `id`, and `tool_call_id` are the fields that pair a call with its226 result; `_convert_message_to_dict` always populates them, so a missing one227 here signals upstream drift or a hand-built message and is logged at228 `WARNING` rather than silently coerced.229 """230 translated: list[Any] = []231 for message in message_dicts:232 if not isinstance(message, dict):233 translated.append(message)234 continue235 role = message.get("role")236 if role == "assistant" and message.get("tool_calls"):237 # Assistant text (if any) becomes a plain message; the calls follow238 # as `function_call` items.239 text = _content_to_text(message.get("content"))240 if text:241 translated.append(242 {"type": "message", "role": "assistant", "content": text}243 )244 for tool_call in message["tool_calls"]:245 function = tool_call.get("function", {})246 call_id = tool_call.get("id")247 name = function.get("name", "")248 if not name or not call_id:249 logger.warning(250 "Assistant tool_call missing identity field "251 "(name=%r, id=%r); the Responses API may reject this "252 "turn or fail to pair the call with its output.",253 name,254 call_id,255 )256 translated.append(257 {258 "type": "function_call",259 "call_id": call_id,260 "name": name,261 "arguments": function.get("arguments", "") or "",262 }263 )264 elif role == "tool":265 content = message.get("content", "")266 output = content if isinstance(content, str) else json.dumps(content)267 call_id = message.get("tool_call_id")268 if not call_id:269 logger.warning(270 "Tool message missing tool_call_id; the Responses API "271 "cannot pair this function_call_output with its call."272 )273 translated.append(274 {275 "type": "function_call_output",276 "call_id": call_id,277 "output": output,278 }279 )280 elif role in {"assistant", "system", "user", "developer"}:281 translated.append({**message, "type": "message"})282 else:283 translated.append(message)284 return translated285286287def _use_responses_api(payload: dict) -> bool:288 """Determine whether to route a payload through the Responses API.289290 The Agent (Responses) API is required for built-in tools and accepts291 fields that Chat Completions would reject — so callers must be routed292 there transparently when those signals appear.293294 Returns True if the payload contains a built-in tool (any element of295 `tools` whose `type` is not `"function"`) or any Responses-only field296 (`input`, `include`, `instructions`, `previous_response_id`).297 """298 uses_builtin_tools = "tools" in payload and any(299 _is_builtin_tool(tool) for tool in payload["tools"]300 )301 matched_fields = _RESPONSES_ONLY_ARGS.intersection(payload)302 if uses_builtin_tools or matched_fields:303 reason = (304 "payload contains a built-in tool (Chat Completions accepts only "305 "function tools)"306 if uses_builtin_tools307 else (308 f"payload sets Responses-only field(s) {sorted(matched_fields)} "309 "(Chat Completions would reject these)"310 )311 )312 logger.debug(313 "Routing through Perplexity Responses API: %s. "314 "Set use_responses_api=False to force Chat Completions.",315 reason,316 )317 return True318 return False319320321def _set_model_name_alias(response_metadata: dict[str, Any]) -> None:322 """Mirror `model` into `model_name`, which langchain-core usage callbacks323 read for cost tracking (the Chat Completions path already sets it).324 """325 if "model" in response_metadata:326 response_metadata["model_name"] = response_metadata["model"]327328329def _get_attr(obj: Any, name: str, default: Any = None) -> Any:330 """Safely fetch an attribute from an SDK object or a dict.331332 Responses SDK payloads arrive either as Pydantic-like SDK objects (server333 responses) or as plain dicts (when callers pass payloads pre-serialized or334 in tests). This helper normalizes both shapes so the rest of the module335 does not have to special-case them.336 """337 if isinstance(obj, dict):338 return obj.get(name, default)339 return getattr(obj, name, default)340341342def _convert_responses_usage(usage: Any) -> UsageMetadata | None:343 """Build `UsageMetadata` from a Responses API usage payload.344345 Returns `None` if `usage` itself is missing or if either token field is346 absent — emitting zeroed `UsageMetadata` would silently undercount usage347 in downstream cost dashboards.348349 Cache hits and cache writes reported under `input_tokens_details` are mapped350 onto the standard `InputTokenDetails` slots so downstream consumers can tell351 cached input from fresh input.352 """353 if usage is None:354 return None355 input_tokens = _get_attr(usage, "input_tokens", None)356 output_tokens = _get_attr(usage, "output_tokens", None)357 if input_tokens is None or output_tokens is None:358 return None359 total_tokens = _get_attr(usage, "total_tokens", None)360 if total_tokens is None:361 total_tokens = input_tokens + output_tokens362363 input_token_details: InputTokenDetails = {}364 details = _get_attr(usage, "input_tokens_details", None)365 if details is not None:366 cache_read = _get_attr(details, "cache_read_input_tokens", None)367 if cache_read is not None:368 input_token_details["cache_read"] = cache_read369 cache_creation = _get_attr(details, "cache_creation_input_tokens", None)370 if cache_creation is not None:371 input_token_details["cache_creation"] = cache_creation372373 return UsageMetadata(374 input_tokens=input_tokens,375 output_tokens=output_tokens,376 total_tokens=total_tokens,377 input_token_details=input_token_details,378 )379380381def _extract_responses_text(response: Any) -> str:382 """Extract assistant text content from a Responses API response.383384 Prefers `response.output_text`, otherwise walks `output[*].content[*].text`.385 """386 text = _get_attr(response, "output_text", None)387 if isinstance(text, str) and text:388 return text389 output = _get_attr(response, "output", None) or []390 parts: list[str] = []391 for item in output:392 item_type = _get_attr(item, "type", None)393 if item_type and item_type != "message":394 continue395 content_blocks = _get_attr(item, "content", None) or []396 for block in content_blocks:397 block_text = _get_attr(block, "text", None)398 if isinstance(block_text, str):399 parts.append(block_text)400 return "".join(parts)401402403def _convert_responses_to_chat_result(response: Any) -> ChatResult:404 """Convert a Responses API response object to a `ChatResult`.405406 Maps `output_text`/`output[*].content[*].text` to `AIMessage.content` and407 surfaces `function_call` items as `tool_calls`. Perplexity-specific fields408 (`citations`, `images`, `related_questions`, `search_results`, `videos`,409 `reasoning_steps`) are placed on `additional_kwargs` to match the shape410 produced by the Chat Completions branch, while transport-level fields411 (`id`, `model`, `status`, `object`) land on `response_metadata`.412 """413 content = _extract_responses_text(response)414415 tool_calls: list[dict[str, Any]] = []416 output = _get_attr(response, "output", None) or []417 for item in output:418 item_type = _get_attr(item, "type", None)419 if item_type == "function_call":420 raw_args = _get_attr(item, "arguments", "") or ""421 try:422 parsed_args = json.loads(raw_args) if raw_args else {}423 except (TypeError, ValueError):424 logger.warning(425 "Failed to parse Perplexity function_call arguments as JSON "426 "for tool %r; preserving raw payload under __raw_arguments__.",427 _get_attr(item, "name", ""),428 exc_info=True,429 )430 parsed_args = {"__raw_arguments__": raw_args}431 tool_calls.append(432 {433 "name": _get_attr(item, "name", ""),434 "args": parsed_args,435 "id": _get_attr(item, "call_id", None)436 or _get_attr(item, "id", None),437 "type": "tool_call",438 }439 )440 elif item_type and item_type != "message":441 logger.debug("Ignoring unhandled Responses output item type: %s", item_type)442443 usage_metadata = _convert_responses_usage(_get_attr(response, "usage", None))444445 additional_kwargs: dict[str, Any] = {}446 for key in (447 "citations",448 "images",449 "related_questions",450 "search_results",451 "videos",452 "reasoning_steps",453 ):454 value = _get_attr(response, key, None)455 if value:456 additional_kwargs[key] = value457458 response_metadata: dict[str, Any] = {}459 for key in ("id", "model", "status", "object"):460 value = _get_attr(response, key, None)461 if value is not None:462 response_metadata[key] = value463 _set_model_name_alias(response_metadata)464465 message = AIMessage(466 content=content,467 additional_kwargs=additional_kwargs,468 tool_calls=tool_calls, # type: ignore[arg-type]469 usage_metadata=usage_metadata,470 response_metadata=response_metadata,471 )472 return ChatResult(generations=[ChatGeneration(message=message)])473474475class PerplexityResponsesStreamError(RuntimeError):476 """Raised when a Perplexity Responses (Agent) API stream fails mid-flight.477478 Carries the structured error fields the API surfaces (`code`, `type`,479 `param`, `request_id`) and the original event payload so observability480 pipelines can inspect them programmatically instead of regex-parsing the481 message string.482 """483484 def __init__(485 self,486 message: str,487 *,488 code: str | None = None,489 error_type: str | None = None,490 param: str | None = None,491 request_id: str | None = None,492 raw_event: Any = None,493 ) -> None:494 super().__init__(message)495 self.code = code496 self.error_type = error_type497 self.param = param498 self.request_id = request_id499 self.raw_event = raw_event500501502def _convert_responses_stream_event_to_chunk(503 event: Any,504) -> ChatGenerationChunk | None:505 """Convert a Responses API streaming event to a `ChatGenerationChunk`.506507 Handles `response.output_text.delta` (text chunk), `response.output_item.done`508 carrying a `function_call` (surfaced as a tool-call chunk), `response.completed`509 (final usage + metadata), and `response.failed` / `response.error`510 (raises `PerplexityResponsesStreamError`). Returns `None` for any other511 event type; unrecognized event types are logged at `DEBUG` so SDK drift is512 diagnosable without flooding logs.513 """514 event_type = _get_attr(event, "type", None)515 if event_type == "response.output_text.delta":516 delta = _get_attr(event, "delta", "") or ""517 return ChatGenerationChunk(message=AIMessageChunk(content=delta))518 if event_type == "response.output_item.done":519 item = _get_attr(event, "item", None)520 if item is not None and _get_attr(item, "type", None) == "function_call":521 # The Responses API delivers the whole function call in one item522 # (no argument deltas), so emit it as a single tool-call chunk.523 return ChatGenerationChunk(524 message=AIMessageChunk(525 content="",526 tool_call_chunks=[527 tool_call_chunk(528 name=_get_attr(item, "name", None),529 args=_get_attr(item, "arguments", None),530 id=_get_attr(item, "call_id", None)531 or _get_attr(item, "id", None),532 index=_get_attr(event, "output_index", 0),533 )534 ],535 )536 )537 return None538 if event_type == "response.completed":539 response = _get_attr(event, "response", None)540 usage_metadata = _convert_responses_usage(_get_attr(response, "usage", None))541 response_metadata: dict[str, Any] = {}542 additional_kwargs: dict[str, Any] = {}543 if response is not None:544 for key in ("id", "model", "status", "object"):545 value = _get_attr(response, key, None)546 if value is not None:547 response_metadata[key] = value548 _set_model_name_alias(response_metadata)549 for key in (550 "citations",551 "images",552 "related_questions",553 "search_results",554 "videos",555 "reasoning_steps",556 ):557 value = _get_attr(response, key, None)558 if value:559 additional_kwargs[key] = value560 return ChatGenerationChunk(561 message=AIMessageChunk(562 content="",563 additional_kwargs=additional_kwargs,564 usage_metadata=usage_metadata,565 response_metadata=response_metadata,566 )567 )568 if event_type in ("response.failed", "response.error"):569 # `response.failed` is the canonical SDK event name; `response.error`570 # is kept as a fallback in case the API surfaces it during transport.571 # Without this branch, a server-side failure mid-stream would yield572 # zero chunks and surface as "No generation chunks were returned"573 # from `BaseChatModel.stream`, obscuring the real error.574 error = _get_attr(event, "error", None)575 message = (576 _get_attr(error, "message", None)577 if error is not None578 else _get_attr(event, "message", None)579 ) or "Perplexity Responses API stream error"580 code = _get_attr(error, "code", None) if error is not None else None581 error_type = _get_attr(error, "type", None) if error is not None else None582 param = _get_attr(error, "param", None) if error is not None else None583 request_id = _get_attr(event, "request_id", None)584 details: list[str] = []585 for label, value in (586 ("code", code),587 ("type", error_type),588 ("param", param),589 ("request_id", request_id),590 ):591 if value is not None:592 details.append(f"{label}={value}")593 if details:594 message = f"{message} ({', '.join(details)})"595 logger.error(596 "Perplexity Responses stream failure: %s",597 message,598 extra={599 "perplexity_error_code": code,600 "perplexity_error_type": error_type,601 "perplexity_error_param": param,602 "perplexity_request_id": request_id,603 },604 )605 raise PerplexityResponsesStreamError(606 message,607 code=code,608 error_type=error_type,609 param=param,610 request_id=request_id,611 raw_event=event,612 )613 logger.debug("Ignoring unhandled Perplexity stream event type: %s", event_type)614 return None615616617class ChatPerplexity(BaseChatModel):618 """`Perplexity AI` Chat models API.619620 Setup:621 To use, you should have the environment variable `PPLX_API_KEY` set to your API key.622 Any parameters that are valid to be passed to the perplexity.create call623 can be passed in, even if not explicitly saved on this class.624625 ```bash626 export PPLX_API_KEY=your_api_key627 ```628629 Key init args - completion params:630 model:631 Name of the model to use. e.g. "sonar"632 temperature:633 Sampling temperature to use.634 max_tokens:635 Maximum number of tokens to generate.636 streaming:637 Whether to stream the results or not.638639 Key init args - client params:640 pplx_api_key:641 API key for PerplexityChat API.642 request_timeout:643 Timeout for requests to PerplexityChat completion API.644 max_retries:645 Maximum number of retries to make when generating.646647 See full list of supported init args and their descriptions in the params section.648649 Instantiate:650651 ```python652 from langchain_perplexity import ChatPerplexity653654 model = ChatPerplexity(model="sonar", temperature=0.7)655 ```656657 Invoke:658659 ```python660 messages = [("system", "You are a chatbot."), ("user", "Hello!")]661 model.invoke(messages)662 ```663664 Invoke with structured output:665666 ```python667 from pydantic import BaseModel668669670 class StructuredOutput(BaseModel):671 role: str672 content: str673674675 model.with_structured_output(StructuredOutput)676 model.invoke(messages)677 ```678679 Stream:680 ```python681 for chunk in model.stream(messages):682 print(chunk.content)683 ```684685 Token usage:686 ```python687 response = model.invoke(messages)688 response.usage_metadata689 ```690691 Response metadata:692 ```python693 response = model.invoke(messages)694 response.response_metadata695 ```696697 Agent API (Responses):698699 Set `use_responses_api=True` to route requests through Perplexity's Agent700 API (the Perplexity-flavored Responses API), or leave it unset to have it701 auto-detected when a built-in tool (e.g. `web_search`) or any702 Responses-only field (`previous_response_id`, `instructions`, `input`,703 `include`) is supplied.704705 The Agent API uses its own model catalog (see706 https://docs.perplexity.ai/docs/agent-api/models). Select routing by707 passing either an explicit `model=` such as `"openai/gpt-5.6-sol"`, or a708 Perplexity `preset` (`"fast"`, `"low"`, `"medium"`, `"high"`, `"xhigh"`,709 or `"wide-research"`) through710 `model_kwargs`. Perplexity's built-in tools and Agent-API-only fields711 also go through `model_kwargs`:712713 ```python714 from langchain_perplexity import ChatPerplexity715716 model = ChatPerplexity(717 use_responses_api=True,718 model_kwargs={719 "preset": "medium",720 "tools": [{"type": "web_search"}],721 },722 )723 model.invoke("What did Perplexity announce most recently?")724 ```725726 Auto-detection example (routes to the Agent API because a built-in727 `web_search` tool is present):728729 ```python730 model = ChatPerplexity(model="openai/gpt-5.6-sol")731 model.invoke(732 "Find recent news about AI.",733 tools=[{"type": "web_search"}],734 )735 ```736 """ # noqa: E501737738 client: Any = Field(default=None, exclude=True)739 async_client: Any = Field(default=None, exclude=True)740741 model: str = "sonar"742 """Model name."""743744 temperature: float = 0.7745 """What sampling temperature to use."""746747 model_kwargs: dict[str, Any] = Field(default_factory=dict)748 """Holds any model parameters valid for `create` call not explicitly specified."""749750 pplx_api_key: SecretStr | None = Field(751 default_factory=secret_from_env("PPLX_API_KEY", default=None), alias="api_key"752 )753 """Perplexity API key."""754755 request_timeout: float | tuple[float, float] | None = Field(None, alias="timeout")756 """Timeout for requests to PerplexityChat completion API."""757758 max_retries: int = 6759 """Maximum number of retries to make when generating."""760761 streaming: bool = False762 """Whether to stream the results or not."""763764 max_tokens: int | None = None765 """Maximum number of tokens to generate."""766767 use_responses_api: bool | None = None768 """Whether to use the Responses (Agent) API instead of the Chat Completions API.769770 If not specified then will be inferred based on invocation params. Specifically,771 requests will be routed to the Responses API when the payload includes a built-in772 tool (any `tools[*]` whose `type` is not `"function"`) or any of the773 Responses-only fields: `previous_response_id`, `instructions`, `input`, `include`.774775 Set explicitly to `True` to always use the Responses API, or `False` to always776 use Chat Completions.777778 !!! warning "Disabled parameters on the Responses (Agent) API"779780 The Perplexity Agent API does not accept Chat-Completions-only knobs.781 When routing through Responses (whether explicitly or by inference):782783 - `temperature`, `top_p`, `top_k`, `stop`, and `metadata` are dropped784 at the boundary with a `WARNING` log so the behavior change is785 discoverable. The class default `temperature` is dropped silently786 (it would otherwise spam every call), but a user-supplied787 `temperature` (init, `invoke(temperature=...)`, or `.bind`) still788 warns.789 - `tool_choice` raises `ValueError` rather than being dropped, since790 downstream agent loops cannot recover from a silently-disabled791 forced tool call.792 - Supplying a `preset` causes `model` to be dropped because the Agent793 API rejects bare Chat-Completions model names when `model` is794 provided. If `model` was explicitly set by the user, a `WARNING` is795 logged so the override is discoverable.796797 Use `use_responses_api=False` if you need any of these parameters to798 take effect.799 """800801 search_mode: Literal["academic", "sec", "web"] | None = None802 """Search mode for specialized content: "academic", "sec", or "web"."""803804 reasoning_effort: Literal["low", "medium", "high"] | None = None805 """Reasoning effort: "low", "medium", or "high" (default)."""806807 language_preference: str | None = None808 """Language preference:"""809810 search_domain_filter: list[str] | None = None811 """Search domain filter: list of domains to filter search results (max 20)."""812813 return_images: bool = False814 """Whether to return images in the response."""815816 return_related_questions: bool = False817 """Whether to return related questions in the response."""818819 search_recency_filter: Literal["day", "week", "month", "year"] | None = None820 """Filter search results by recency: "day", "week", "month", or "year"."""821822 search_after_date_filter: str | None = None823 """Search after date filter: date in format "MM/DD/YYYY" (default)."""824825 search_before_date_filter: str | None = None826 """Only return results before this date (format: MM/DD/YYYY)."""827828 last_updated_after_filter: str | None = None829 """Only return results updated after this date (format: MM/DD/YYYY)."""830831 last_updated_before_filter: str | None = None832 """Only return results updated before this date (format: MM/DD/YYYY)."""833834 disable_search: bool = False835 """Whether to disable web search entirely."""836837 enable_search_classifier: bool = False838 """Whether to enable the search classifier."""839840 web_search_options: WebSearchOptions | None = None841 """Configuration for web search behavior including Pro Search."""842843 media_response: MediaResponse | None = None844 """Media response: "images", "videos", or "none" (default)."""845846 model_config = ConfigDict(populate_by_name=True)847848 @property849 def lc_secrets(self) -> dict[str, str]:850 return {"pplx_api_key": "PPLX_API_KEY"}851852 @model_validator(mode="before")853 @classmethod854 def build_extra(cls, values: dict[str, Any]) -> Any:855 """Build extra kwargs from additional params that were passed in."""856 all_required_field_names = get_pydantic_field_names(cls)857 extra = values.get("model_kwargs", {})858 for field_name in list(values):859 if field_name in extra:860 raise ValueError(f"Found {field_name} supplied twice.")861 if field_name not in all_required_field_names:862 logger.warning(863 f"""WARNING! {field_name} is not a default parameter.864 {field_name} was transferred to model_kwargs.865 Please confirm that {field_name} is what you intended."""866 )867 extra[field_name] = values.pop(field_name)868869 invalid_model_kwargs = all_required_field_names.intersection(extra.keys())870 if invalid_model_kwargs:871 raise ValueError(872 f"Parameters {invalid_model_kwargs} should be specified explicitly. "873 f"Instead they were passed in as part of `model_kwargs` parameter."874 )875876 values["model_kwargs"] = extra877 return values878879 @model_validator(mode="after")880 def _set_perplexity_version(self) -> Self:881 """Set package version in metadata."""882 self._add_version("langchain-perplexity", __version__)883 return self884885 @model_validator(mode="after")886 def validate_environment(self) -> Self:887 """Validate that api key and python package exists in environment."""888 pplx_api_key = (889 self.pplx_api_key.get_secret_value() if self.pplx_api_key else None890 )891892 client_params: dict[str, Any] = {893 "api_key": pplx_api_key,894 "max_retries": self.max_retries,895 }896 if self.request_timeout is not None:897 client_params["timeout"] = self.request_timeout898899 if not self.client:900 self.client = Perplexity(**client_params)901902 if not self.async_client:903 self.async_client = AsyncPerplexity(**client_params)904905 return self906907 def _resolve_model_profile(self) -> ModelProfile | None:908 return _get_default_model_profile(self.model) or None909910 @property911 def _default_params(self) -> dict[str, Any]:912 """Get the default parameters for calling PerplexityChat API."""913 params: dict[str, Any] = {914 "max_tokens": self.max_tokens,915 "stream": self.streaming,916 "temperature": self.temperature,917 }918 if self.search_mode:919 params["search_mode"] = self.search_mode920 if self.reasoning_effort:921 params["reasoning_effort"] = self.reasoning_effort922 if self.language_preference:923 params["language_preference"] = self.language_preference924 if self.search_domain_filter:925 params["search_domain_filter"] = self.search_domain_filter926 if self.return_images:927 params["return_images"] = self.return_images928 if self.return_related_questions:929 params["return_related_questions"] = self.return_related_questions930 if self.search_recency_filter:931 params["search_recency_filter"] = self.search_recency_filter932 if self.search_after_date_filter:933 params["search_after_date_filter"] = self.search_after_date_filter934 if self.search_before_date_filter:935 params["search_before_date_filter"] = self.search_before_date_filter936 if self.last_updated_after_filter:937 params["last_updated_after_filter"] = self.last_updated_after_filter938 if self.last_updated_before_filter:939 params["last_updated_before_filter"] = self.last_updated_before_filter940 if self.disable_search:941 params["disable_search"] = self.disable_search942 if self.enable_search_classifier:943 params["enable_search_classifier"] = self.enable_search_classifier944 if self.web_search_options:945 params["web_search_options"] = self.web_search_options.model_dump(946 exclude_none=True947 )948 if self.media_response:949 if "extra_body" not in params:950 params["extra_body"] = {}951 params["extra_body"]["media_response"] = self.media_response.model_dump(952 exclude_none=True953 )954955 return {**params, **self.model_kwargs}956957 def _convert_message_to_dict(self, message: BaseMessage) -> dict[str, Any]:958 message_dict: dict[str, Any]959 if isinstance(message, ChatMessage):960 message_dict = {"role": message.role, "content": message.content}961 elif isinstance(message, SystemMessage):962 message_dict = {"role": "system", "content": message.content}963 elif isinstance(message, HumanMessage):964 message_dict = {"role": "user", "content": message.content}965 elif isinstance(message, AIMessage):966 message_dict = {"role": "assistant", "content": message.content}967 if message.tool_calls or message.invalid_tool_calls:968 message_dict["tool_calls"] = [969 {970 "id": tool_call["id"],971 "type": "function",972 "function": {973 "name": tool_call["name"],974 "arguments": json.dumps(975 tool_call["args"], ensure_ascii=False976 ),977 },978 }979 for tool_call in message.tool_calls980 ] + [981 {982 "id": tool_call["id"],983 "type": "function",984 "function": {985 "name": tool_call["name"],986 "arguments": tool_call["args"],987 },988 }989 for tool_call in message.invalid_tool_calls990 ]991 # OpenAI-compatible APIs reject empty-string content alongside992 # tool_calls; send null instead.993 message_dict["content"] = message_dict["content"] or None994 elif isinstance(message, ToolMessage):995 message_dict = {996 "role": "tool",997 "content": message.content,998 "tool_call_id": message.tool_call_id,999 }1000 else:1001 raise TypeError(f"Got unknown type {message}")1002 return message_dict10031004 def _create_message_dicts(1005 self, messages: list[BaseMessage], stop: list[str] | None1006 ) -> tuple[list[dict[str, Any]], dict[str, Any]]:1007 params = dict(self._invocation_params)1008 if stop is not None:1009 if "stop" in params:1010 raise ValueError("`stop` found in both the input and default params.")1011 params["stop"] = stop1012 message_dicts = [self._convert_message_to_dict(m) for m in messages]1013 return message_dicts, params10141015 def _use_responses_api(self, payload: dict) -> bool:1016 """Return True if `payload` should be routed through the Responses API.10171018 Honors `self.use_responses_api` when set explicitly; otherwise delegates1019 to the module-level `_use_responses_api` heuristic.1020 """1021 if isinstance(self.use_responses_api, bool):1022 return self.use_responses_api1023 return _use_responses_api(payload)10241025 def _to_responses_payload(1026 self,1027 message_dicts: list[dict[str, Any]],1028 params: dict[str, Any],1029 *,1030 user_set_keys: set[str] | None = None,1031 ) -> dict[str, Any]:1032 """Translate a Chat Completions-style payload to the Responses API shape.10331034 Renames `messages` to `input` and `max_tokens` to `max_output_tokens`.1035 `None`-valued params are dropped. Chat-Completions-only sampling/control1036 parameters that the Perplexity Responses (Agent) API does not accept1037 (`temperature`, `top_p`, `top_k`, `stop`, `metadata`) are dropped at1038 the boundary because the typed SDK signature would otherwise raise a1039 `TypeError`; every drop emits a `WARNING`-level log on each call,1040 except the class-default `temperature`, which is suppressed because1041 `_default_params` injects it on every call regardless of user intent.10421043 `tool_choice` is rejected with `ValueError` rather than dropped: it is1044 a control-flow primitive (forced/required tool selection) that agent1045 loops depend on, so silently disabling it would produce wrong1046 completions while returning HTTP 200.10471048 When a `preset` is supplied, `model` is dropped — the Agent API1049 validates `model` strictly (it expects `provider/model` format), and1050 a preset selects routing/model behavior on its own. If the user1051 explicitly set `model` (init or via `kwargs`), a `WARNING` is logged1052 so the override is discoverable.10531054 Unknown or Perplexity-specific keys (including `previous_response_id`1055 and `include`, documented Perplexity features that the typed SDK1056 signature does not currently expose) are forwarded under `extra_body`.10571058 Args:1059 message_dicts: Chat messages already serialized to the Chat1060 Completions shape; promoted to `payload["input"]`.1061 params: Merged invocation params from `_default_params` and the1062 per-call `kwargs`.1063 user_set_keys: Keys the user explicitly supplied for this call1064 (typically `set(kwargs)`). Used in combination with1065 `self.model_fields_set` to distinguish class defaults from1066 explicit user intent for `temperature` and `model`.10671068 Raises:1069 ValueError: If `tool_choice` is supplied — the Responses API1070 cannot honor it.1071 TypeError: If a caller supplied an `extra_body` that is not a1072 `dict` — silently dropping subsequent params would mask1073 user-set search/filter knobs.1074 """1075 payload: dict[str, Any] = {"input": _translate_responses_input(message_dicts)}1076 runtime_keys = user_set_keys or set()1077 user_set_temperature = (1078 "temperature" in self.model_fields_set or "temperature" in runtime_keys1079 )1080 user_set_model = "model" in self.model_fields_set or "model" in runtime_keys1081 # Collect dropped values so the warning can name them.1082 dropped_for_warning: dict[str, Any] = {}1083 for key, value in params.items():1084 if value is None:1085 continue1086 if key == "messages":1087 continue1088 if key in _RESPONSES_DROP_KEYS:1089 # Suppress the warning for the class-default `temperature`,1090 # which `_default_params` injects on every call and would1091 # otherwise spam users who never asked for it.1092 if key != "temperature" or user_set_temperature:1093 dropped_for_warning[key] = value1094 continue1095 if key == "tool_choice":1096 msg = (1097 "Perplexity Responses (Agent) API does not support "1098 "`tool_choice`. Forced tool selection is unavailable on "1099 "this route. Set `use_responses_api=False` to use Chat "1100 "Completions, or remove `tool_choice` to let the model "1101 "decide."1102 )1103 raise ValueError(msg)1104 if key == "max_tokens":1105 payload["max_output_tokens"] = value1106 continue1107 if key == "tools":1108 # Function tools must be flattened to the Responses-API shape;1109 # built-in tools (web_search, etc.) pass through unchanged.1110 payload["tools"] = [_flatten_responses_tool(tool) for tool in value]1111 continue1112 if key in _RESPONSES_PASSTHROUGH_KEYS:1113 payload[key] = dict(value) if isinstance(value, dict) else value1114 continue1115 # Unknown / Perplexity-specific keys: route under extra_body so the1116 # SDK forwards them to the Agent API without breaking strict typing.1117 extra_body = payload.setdefault("extra_body", {})1118 if not isinstance(extra_body, dict):1119 msg = (1120 "`extra_body` must be a dict to forward Perplexity-specific "1121 f"parameters to the Responses API, got "1122 f"{type(extra_body).__name__}={extra_body!r}; cannot merge "1123 f"user-set key {key!r}."1124 )1125 raise TypeError(msg)1126 extra_body[key] = value1127 # When the caller selected a preset, defer model selection to it: the1128 # Agent API rejects bare Chat-Completions model names like `sonar-pro`1129 # outright when `model` is set, even if a preset is also present.1130 if "preset" in payload:1131 dropped_model = payload.pop("model", None)1132 if user_set_model and dropped_model is not None:1133 logger.warning(1134 "Perplexity Agent API rejects `model` when `preset` is "1135 "set; dropping explicit model=%r in favor of preset=%r.",1136 dropped_model,1137 payload["preset"],1138 )1139 if dropped_for_warning:1140 logger.warning(1141 "Perplexity Responses (Agent) API does not accept %s; the "1142 "following values were dropped: %s. Use the Chat Completions "1143 "API (set `use_responses_api=False`) if you need them.",1144 sorted(dropped_for_warning),1145 dropped_for_warning,1146 )1147 return payload11481149 def _convert_delta_to_message_chunk(1150 self, _dict: Mapping[str, Any], default_class: type[BaseMessageChunk]1151 ) -> BaseMessageChunk:1152 role = _dict.get("role")1153 content = _dict.get("content") or ""1154 additional_kwargs: dict = {}1155 if _dict.get("function_call"):1156 function_call = dict(_dict["function_call"])1157 if "name" in function_call and function_call["name"] is None:1158 function_call["name"] = ""1159 additional_kwargs["function_call"] = function_call1160 if _dict.get("tool_calls"):1161 additional_kwargs["tool_calls"] = _dict["tool_calls"]11621163 if role == "user" or default_class == HumanMessageChunk:1164 return HumanMessageChunk(content=content)1165 elif role == "assistant" or default_class == AIMessageChunk:1166 return AIMessageChunk(content=content, additional_kwargs=additional_kwargs)1167 elif role == "system" or default_class == SystemMessageChunk:1168 return SystemMessageChunk(content=content)1169 elif role == "function" or default_class == FunctionMessageChunk:1170 return FunctionMessageChunk(content=content, name=_dict["name"])1171 elif role == "tool" or default_class == ToolMessageChunk:1172 return ToolMessageChunk(content=content, tool_call_id=_dict["tool_call_id"])1173 elif role or default_class == ChatMessageChunk:1174 return ChatMessageChunk(content=content, role=role) # type: ignore[arg-type]1175 else:1176 return default_class(content=content) # type: ignore[call-arg]11771178 def _stream(1179 self,1180 messages: list[BaseMessage],1181 stop: list[str] | None = None,1182 run_manager: CallbackManagerForLLMRun | None = None,1183 **kwargs: Any,1184 ) -> Iterator[ChatGenerationChunk]:1185 message_dicts, params = self._create_message_dicts(messages, stop)1186 runtime_keys = set(kwargs)1187 if stop is not None:1188 runtime_keys.add("stop")1189 params = {**params, **kwargs}1190 default_chunk_class = AIMessageChunk1191 params.pop("stream", None)1192 if self._use_responses_api({**params, "messages": message_dicts}):1193 responses_payload = self._to_responses_payload(1194 message_dicts, params, user_set_keys=runtime_keys1195 )1196 responses_payload["stream"] = True1197 stream_events = self.client.responses.create(**responses_payload)1198 # Trusts SDK SSE decoding (perplexityai>=0.34.1, upstream issue1199 # perplexityai-python#53). `_convert_responses_stream_event_to_chunk`1200 # already handles both SDK objects and dicts via `_get_attr`.1201 for event in stream_events:1202 response_chunk = _convert_responses_stream_event_to_chunk(event)1203 if response_chunk is None:1204 continue1205 if run_manager:1206 run_manager.on_llm_new_token(1207 response_chunk.text, chunk=response_chunk1208 )1209 yield response_chunk1210 return1211 if stop:1212 params["stop_sequences"] = stop1213 stream_resp = self.client.chat.completions.create(1214 messages=message_dicts, stream=True, **params1215 )1216 first_chunk = True1217 prev_total_usage: UsageMetadata | None = None12181219 added_model_name: bool = False1220 added_search_queries: bool = False1221 added_search_context_size: bool = False1222 for chunk in stream_resp:1223 if not isinstance(chunk, dict):1224 chunk = chunk.model_dump()1225 # Collect standard usage metadata (transform from aggregate to delta)1226 if total_usage := chunk.get("usage"):1227 lc_total_usage = _create_usage_metadata(total_usage)1228 if prev_total_usage:1229 usage_metadata: UsageMetadata | None = subtract_usage(1230 lc_total_usage, prev_total_usage1231 )1232 else:1233 usage_metadata = lc_total_usage1234 prev_total_usage = lc_total_usage1235 else:1236 usage_metadata = None1237 generation_info = {}1238 if (model_name := chunk.get("model")) and not added_model_name:1239 generation_info["model_name"] = model_name1240 added_model_name = True1241 if total_usage := chunk.get("usage"):1242 if num_search_queries := total_usage.get("num_search_queries"):1243 if not added_search_queries:1244 generation_info["num_search_queries"] = num_search_queries1245 added_search_queries = True1246 if not added_search_context_size:1247 if search_context_size := total_usage.get("search_context_size"):1248 generation_info["search_context_size"] = search_context_size1249 added_search_context_size = True12501251 choices = chunk.get("choices") or []1252 if len(choices) == 0:1253 # Usage-only or otherwise empty chunk: still yield so the stream1254 # is never empty and downstream callers receive usage metadata.1255 message = AIMessageChunk(content="", usage_metadata=usage_metadata)1256 yield ChatGenerationChunk(1257 message=message, generation_info=generation_info or None1258 )1259 continue1260 choice = choices[0]12611262 additional_kwargs = {}1263 if first_chunk:1264 additional_kwargs["citations"] = chunk.get("citations", [])1265 for attr in ["images", "related_questions", "search_results"]:1266 if attr in chunk:1267 additional_kwargs[attr] = chunk[attr]12681269 if chunk.get("videos"):1270 additional_kwargs["videos"] = chunk["videos"]12711272 if chunk.get("reasoning_steps"):1273 additional_kwargs["reasoning_steps"] = chunk["reasoning_steps"]12741275 chunk = self._convert_delta_to_message_chunk(1276 choice["delta"], default_chunk_class1277 )12781279 if isinstance(chunk, AIMessageChunk) and usage_metadata:1280 chunk.usage_metadata = usage_metadata12811282 if first_chunk:1283 chunk.additional_kwargs |= additional_kwargs1284 first_chunk = False12851286 if finish_reason := choice.get("finish_reason"):1287 generation_info["finish_reason"] = finish_reason12881289 default_chunk_class = chunk.__class__1290 chunk = ChatGenerationChunk(message=chunk, generation_info=generation_info)1291 if run_manager:1292 run_manager.on_llm_new_token(chunk.text, chunk=chunk)1293 yield chunk12941295 async def _astream(1296 self,1297 messages: list[BaseMessage],1298 stop: list[str] | None = None,1299 run_manager: AsyncCallbackManagerForLLMRun | None = None,1300 **kwargs: Any,1301 ) -> AsyncIterator[ChatGenerationChunk]:1302 message_dicts, params = self._create_message_dicts(messages, stop)1303 runtime_keys = set(kwargs)1304 if stop is not None:1305 runtime_keys.add("stop")1306 params = {**params, **kwargs}1307 default_chunk_class = AIMessageChunk1308 params.pop("stream", None)1309 if self._use_responses_api({**params, "messages": message_dicts}):1310 responses_payload = self._to_responses_payload(1311 message_dicts, params, user_set_keys=runtime_keys1312 )1313 responses_payload["stream"] = True1314 stream_events = await self.async_client.responses.create(1315 **responses_payload1316 )1317 # See sync `_stream` for SDK trust rationale (perplexityai>=0.34.1).1318 async for event in stream_events:1319 response_chunk = _convert_responses_stream_event_to_chunk(event)1320 if response_chunk is None:1321 continue1322 if run_manager:1323 await run_manager.on_llm_new_token(1324 response_chunk.text, chunk=response_chunk1325 )1326 yield response_chunk1327 return1328 if stop:1329 params["stop_sequences"] = stop1330 stream_resp = await self.async_client.chat.completions.create(1331 messages=message_dicts, stream=True, **params1332 )1333 first_chunk = True1334 prev_total_usage: UsageMetadata | None = None13351336 added_model_name: bool = False1337 added_search_queries: bool = False1338 added_search_context_size: bool = False1339 async for chunk in stream_resp:1340 if not isinstance(chunk, dict):1341 chunk = chunk.model_dump()1342 if total_usage := chunk.get("usage"):1343 lc_total_usage = _create_usage_metadata(total_usage)1344 if prev_total_usage:1345 usage_metadata: UsageMetadata | None = subtract_usage(1346 lc_total_usage, prev_total_usage1347 )1348 else:1349 usage_metadata = lc_total_usage1350 prev_total_usage = lc_total_usage1351 else:1352 usage_metadata = None1353 generation_info = {}1354 if (model_name := chunk.get("model")) and not added_model_name:1355 generation_info["model_name"] = model_name1356 added_model_name = True1357 if total_usage := chunk.get("usage"):1358 if num_search_queries := total_usage.get("num_search_queries"):1359 if not added_search_queries:1360 generation_info["num_search_queries"] = num_search_queries1361 added_search_queries = True1362 if not added_search_context_size:1363 if search_context_size := total_usage.get("search_context_size"):1364 generation_info["search_context_size"] = search_context_size1365 added_search_context_size = True13661367 choices = chunk.get("choices") or []1368 if len(choices) == 0:1369 # Usage-only or otherwise empty chunk: still yield so the stream1370 # is never empty and downstream callers receive usage metadata.1371 message = AIMessageChunk(content="", usage_metadata=usage_metadata)1372 yield ChatGenerationChunk(1373 message=message, generation_info=generation_info or None1374 )1375 continue1376 choice = choices[0]13771378 additional_kwargs = {}1379 if first_chunk:1380 additional_kwargs["citations"] = chunk.get("citations", [])1381 for attr in ["images", "related_questions", "search_results"]:1382 if attr in chunk:1383 additional_kwargs[attr] = chunk[attr]13841385 if chunk.get("videos"):1386 additional_kwargs["videos"] = chunk["videos"]13871388 if chunk.get("reasoning_steps"):1389 additional_kwargs["reasoning_steps"] = chunk["reasoning_steps"]13901391 chunk = self._convert_delta_to_message_chunk(1392 choice["delta"], default_chunk_class1393 )13941395 if isinstance(chunk, AIMessageChunk) and usage_metadata:1396 chunk.usage_metadata = usage_metadata13971398 if first_chunk:1399 chunk.additional_kwargs |= additional_kwargs1400 first_chunk = False14011402 if finish_reason := choice.get("finish_reason"):1403 generation_info["finish_reason"] = finish_reason14041405 default_chunk_class = chunk.__class__1406 chunk = ChatGenerationChunk(message=chunk, generation_info=generation_info)1407 if run_manager:1408 await run_manager.on_llm_new_token(chunk.text, chunk=chunk)1409 yield chunk14101411 def _generate(1412 self,1413 messages: list[BaseMessage],1414 stop: list[str] | None = None,1415 run_manager: CallbackManagerForLLMRun | None = None,1416 **kwargs: Any,1417 ) -> ChatResult:1418 if self.streaming:1419 stream_iter = self._stream(1420 messages, stop=stop, run_manager=run_manager, **kwargs1421 )1422 if stream_iter:1423 return generate_from_stream(stream_iter)1424 message_dicts, params = self._create_message_dicts(messages, stop)1425 runtime_keys = set(kwargs)1426 if stop is not None:1427 runtime_keys.add("stop")1428 params = {**params, **kwargs}1429 if self._use_responses_api({**params, "messages": message_dicts}):1430 responses_payload = self._to_responses_payload(1431 message_dicts, params, user_set_keys=runtime_keys1432 )1433 responses_payload.pop("stream", None)1434 response = self.client.responses.create(**responses_payload)1435 return _convert_responses_to_chat_result(response)1436 response = self.client.chat.completions.create(messages=message_dicts, **params)14371438 if hasattr(response, "usage") and response.usage:1439 usage_dict = response.usage.model_dump()1440 usage_metadata = _create_usage_metadata(usage_dict)1441 else:1442 usage_metadata = None1443 usage_dict = {}14441445 additional_kwargs = {}1446 for attr in ["citations", "images", "related_questions", "search_results"]:1447 if hasattr(response, attr) and getattr(response, attr):1448 additional_kwargs[attr] = getattr(response, attr)14491450 if hasattr(response, "videos") and response.videos:1451 additional_kwargs["videos"] = [1452 v.model_dump() if hasattr(v, "model_dump") else v1453 for v in response.videos1454 ]14551456 if hasattr(response, "reasoning_steps") and response.reasoning_steps:1457 additional_kwargs["reasoning_steps"] = [1458 r.model_dump() if hasattr(r, "model_dump") else r1459 for r in response.reasoning_steps1460 ]14611462 response_metadata: dict[str, Any] = {1463 "model_name": getattr(response, "model", self.model)1464 }1465 if num_search_queries := usage_dict.get("num_search_queries"):1466 response_metadata["num_search_queries"] = num_search_queries1467 if search_context_size := usage_dict.get("search_context_size"):1468 response_metadata["search_context_size"] = search_context_size14691470 message = AIMessage(1471 content=response.choices[0].message.content,1472 additional_kwargs=additional_kwargs,1473 usage_metadata=usage_metadata,1474 response_metadata=response_metadata,1475 )1476 return ChatResult(generations=[ChatGeneration(message=message)])14771478 async def _agenerate(1479 self,1480 messages: list[BaseMessage],1481 stop: list[str] | None = None,1482 run_manager: AsyncCallbackManagerForLLMRun | None = None,1483 **kwargs: Any,1484 ) -> ChatResult:1485 if self.streaming:1486 stream_iter = self._astream(1487 messages, stop=stop, run_manager=run_manager, **kwargs1488 )1489 if stream_iter:1490 return await agenerate_from_stream(stream_iter)1491 message_dicts, params = self._create_message_dicts(messages, stop)1492 runtime_keys = set(kwargs)1493 if stop is not None:1494 runtime_keys.add("stop")1495 params = {**params, **kwargs}1496 if self._use_responses_api({**params, "messages": message_dicts}):1497 responses_payload = self._to_responses_payload(1498 message_dicts, params, user_set_keys=runtime_keys1499 )1500 responses_payload.pop("stream", None)1501 response = await self.async_client.responses.create(**responses_payload)1502 return _convert_responses_to_chat_result(response)1503 response = await self.async_client.chat.completions.create(1504 messages=message_dicts, **params1505 )15061507 if hasattr(response, "usage") and response.usage:1508 usage_dict = response.usage.model_dump()1509 usage_metadata = _create_usage_metadata(usage_dict)1510 else:1511 usage_metadata = None1512 usage_dict = {}15131514 additional_kwargs = {}1515 for attr in ["citations", "images", "related_questions", "search_results"]:1516 if hasattr(response, attr) and getattr(response, attr):1517 additional_kwargs[attr] = getattr(response, attr)15181519 if hasattr(response, "videos") and response.videos:1520 additional_kwargs["videos"] = [1521 v.model_dump() if hasattr(v, "model_dump") else v1522 for v in response.videos1523 ]15241525 if hasattr(response, "reasoning_steps") and response.reasoning_steps:1526 additional_kwargs["reasoning_steps"] = [1527 r.model_dump() if hasattr(r, "model_dump") else r1528 for r in response.reasoning_steps1529 ]15301531 response_metadata: dict[str, Any] = {1532 "model_name": getattr(response, "model", self.model)1533 }1534 if num_search_queries := usage_dict.get("num_search_queries"):1535 response_metadata["num_search_queries"] = num_search_queries1536 if search_context_size := usage_dict.get("search_context_size"):1537 response_metadata["search_context_size"] = search_context_size15381539 message = AIMessage(1540 content=response.choices[0].message.content,1541 additional_kwargs=additional_kwargs,1542 usage_metadata=usage_metadata,1543 response_metadata=response_metadata,1544 )1545 return ChatResult(generations=[ChatGeneration(message=message)])15461547 @property1548 def _invocation_params(self) -> Mapping[str, Any]:1549 """Get the parameters used to invoke the model."""1550 pplx_creds: dict[str, Any] = {"model": self.model}1551 return {**pplx_creds, **self._default_params}15521553 @property1554 def _llm_type(self) -> str:1555 """Return type of chat model."""1556 return "perplexitychat"15571558 def bind_tools(1559 self,1560 tools: Sequence[dict[str, Any] | type | Callable | BaseTool],1561 *,1562 tool_choice: dict | str | bool | None = None,1563 strict: bool | None = None,1564 **kwargs: Any,1565 ) -> Runnable[LanguageModelInput, AIMessage]:1566 """Bind tool-like objects to this chat model.15671568 Client-side function tools require the Perplexity Responses (Agent) API:1569 construct the model with `use_responses_api=True` and a tool-capable1570 model such as `openai/gpt-5`. The `sonar` family does not support1571 client-side function tools.15721573 Args:1574 tools: A list of tool definitions to bind to this chat model.1575 Supports any tool handled by1576 [convert_to_openai_tool][langchain_core.utils.function_calling.convert_to_openai_tool]1577 (Pydantic models, `TypedDict` classes, callables, `BaseTool`,1578 or OpenAI-format dicts), as well as Perplexity built-in tools such1579 as `{"type": "web_search"}`, which are passed through unchanged.1580 tool_choice: Which tool the model should use. Normalized here for API1581 parity with `langchain-openai` (a tool name, `"auto"`, `"none"`,1582 `"any"`/`"required"`/`True`, or an OpenAI-style dict) and stored1583 on the binding, but the Perplexity Responses (Agent) API does not1584 currently honor it: a non-empty `tool_choice` makes1585 `_to_responses_payload` raise `ValueError` at invoke time on the1586 Responses route. The restriction can be relaxed if Perplexity1587 adds `tool_choice` support.1588 strict: If `True`, the tool parameter schema is sent with `strict`1589 enabled. If `None` (default), the flag is omitted.1590 kwargs: Any additional parameters are passed directly to `bind`.1591 """1592 formatted_tools = [1593 tool1594 if isinstance(tool, dict) and _is_builtin_tool(tool)1595 else convert_to_openai_tool(tool, strict=strict)1596 for tool in tools1597 ]1598 if tool_choice:1599 tool_names = [1600 t["function"]["name"] if "function" in t else t.get("name")1601 for t in formatted_tools1602 ]1603 if isinstance(tool_choice, str):1604 if tool_choice in tool_names:1605 tool_choice = {1606 "type": "function",1607 "function": {"name": tool_choice},1608 }1609 # 'any' is not native to the OpenAI schema; map it to 'required'1610 # for parity with providers that use 'any'.1611 elif tool_choice == "any":1612 tool_choice = "required"1613 elif isinstance(tool_choice, bool):1614 tool_choice = "required"1615 elif isinstance(tool_choice, dict):1616 pass1617 else:1618 msg = (1619 "Unrecognized tool_choice type. Expected str, bool or dict. "1620 f"Received: {tool_choice}"1621 )1622 raise ValueError(msg)1623 kwargs["tool_choice"] = tool_choice1624 return super().bind(tools=formatted_tools, **kwargs)16251626 def with_structured_output(1627 self,1628 schema: _DictOrPydanticClass | None = None,1629 *,1630 method: Literal["json_schema"] = "json_schema",1631 include_raw: bool = False,1632 strict: bool | None = None,1633 **kwargs: Any,1634 ) -> Runnable[LanguageModelInput, _DictOrPydantic]:1635 """Model wrapper that returns outputs formatted to match the given schema for Preplexity.1636 Currently, Perplexity only supports "json_schema" method for structured output1637 as per their [official documentation](https://docs.perplexity.ai/guides/structured-outputs).16381639 Args:1640 schema: The output schema. Can be passed in as:16411642 - a JSON Schema,1643 - a `TypedDict` class,1644 - or a Pydantic class16451646 method: The method for steering model generation, currently only support:16471648 - `'json_schema'`: Use the JSON Schema to parse the model output164916501651 include_raw:1652 If `False` then only the parsed structured output is returned.16531654 If an error occurs during model output parsing it will be raised.16551656 If `True` then both the raw model response (a `BaseMessage`) and the1657 parsed model response will be returned.16581659 If an error occurs during output parsing it will be caught and returned1660 as well.16611662 The final output is always a `dict` with keys `'raw'`, `'parsed'`, and1663 `'parsing_error'`.1664 strict:1665 Unsupported: whether to enable strict schema adherence when generating1666 the output. This parameter is included for compatibility with other1667 chat models, but is currently ignored.16681669 kwargs: Additional keyword args aren't supported.16701671 Returns:1672 A `Runnable` that takes same inputs as a1673 `langchain_core.language_models.chat.BaseChatModel`. If `include_raw` is1674 `False` and `schema` is a Pydantic class, `Runnable` outputs an instance1675 of `schema` (i.e., a Pydantic object). Otherwise, if `include_raw` is1676 `False` then `Runnable` outputs a `dict`.16771678 If `include_raw` is `True`, then `Runnable` outputs a `dict` with keys:16791680 - `'raw'`: `BaseMessage`1681 - `'parsed'`: `None` if there was a parsing error, otherwise the type1682 depends on the `schema` as described above.1683 - `'parsing_error'`: `BaseException | None`1684 """ # noqa: E5011685 if method in ("function_calling", "json_mode"):1686 method = "json_schema"1687 if method == "json_schema":1688 if schema is None:1689 raise ValueError(1690 "schema must be specified when method is not 'json_schema'. "1691 "Received None."1692 )1693 is_pydantic_schema = _is_pydantic_class(schema)1694 response_format = convert_to_json_schema(schema)1695 llm = self.bind(1696 response_format={1697 "type": "json_schema",1698 "json_schema": {"schema": response_format},1699 },1700 ls_structured_output_format={1701 "kwargs": {"method": method},1702 "schema": response_format,1703 },1704 )1705 output_parser = (1706 ReasoningStructuredOutputParser(pydantic_object=schema) # type: ignore[arg-type]1707 if is_pydantic_schema1708 else ReasoningJsonOutputParser()1709 )1710 else:1711 raise ValueError(1712 f"Unrecognized method argument. Expected 'json_schema' Received:\1713 '{method}'"1714 )17151716 if include_raw:1717 parser_assign = RunnablePassthrough.assign(1718 parsed=itemgetter("raw") | output_parser, parsing_error=lambda _: None1719 )1720 parser_none = RunnablePassthrough.assign(parsed=lambda _: None)1721 parser_with_fallback = parser_assign.with_fallbacks(1722 [parser_none], exception_key="parsing_error"1723 )1724 return RunnableMap(raw=llm) | parser_with_fallback1725 else:1726 return llm | output_parser
Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.