libs/partners/perplexity/langchain_perplexity/chat_models.py PYTHON 1,688 lines View on github.com → Search inside
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

Code quality findings 32

Overuse may indicate design issues; consider polymorphism
isinstance-overuse
return isinstance(obj, type) and is_basemodel_subclass(obj)
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
if tool.get("type") == "function" and isinstance(tool.get("function"), dict):
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
if isinstance(content, str):
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
if isinstance(content, list):
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
if isinstance(block, str):
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
elif isinstance(block, dict) and block.get("type") == "text":
Use isinstance() for type checking instead of type()
type-check
logger.debug("Dropping unexpected content type %s on tool turn.", type(content))
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
if not isinstance(message, dict):
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
output = content if isinstance(content, str) else json.dumps(content)
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
if isinstance(obj, dict):
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
if isinstance(text, str) and text:
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
if isinstance(block_text, str):
Use logging module for better control and configurability
print-statement
print(chunk.content)
Ensure functions have docstrings for documentation
missing-docstring
def lc_secrets(self) -> dict[str, str]:
Avoid unnecessary list conversions; use generators where possible
unnecessary-list
for field_name in list(values):
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
if isinstance(message, ChatMessage):
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
elif isinstance(message, SystemMessage):
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
elif isinstance(message, HumanMessage):
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
elif isinstance(message, AIMessage):
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
elif isinstance(message, ToolMessage):
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
if isinstance(self.use_responses_api, bool):
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
if not isinstance(extra_body, dict):
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
if not isinstance(chunk, dict):
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
if isinstance(chunk, AIMessageChunk) and usage_metadata:
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
if not isinstance(chunk, dict):
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
if isinstance(chunk, AIMessageChunk) and usage_metadata:
Ensure functions have docstrings for documentation
missing-docstring
def bind_tools(
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
if isinstance(tool, dict) and _is_builtin_tool(tool)
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
if isinstance(tool_choice, str):
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
elif isinstance(tool_choice, bool):
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
elif isinstance(tool_choice, dict):
Ensure functions have docstrings for documentation
missing-docstring
def with_structured_output(

Get this view in your editor

Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.