libs/partners/perplexity/langchain_perplexity/chat_models.py PYTHON 1,727 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    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

Code quality findings 33

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