libs/partners/perplexity/langchain_perplexity/chat_models.py PYTHON 832 lines View on github.com → Search inside
1"""Wrapper around Perplexity APIs."""23from __future__ import annotations45import logging6from collections.abc import AsyncIterator, Iterator, Mapping7from operator import itemgetter8from typing import Any, Literal, TypeAlias, cast910from langchain_core.callbacks import (11    AsyncCallbackManagerForLLMRun,12    CallbackManagerForLLMRun,13)14from langchain_core.language_models import (15    LanguageModelInput,16    ModelProfile,17    ModelProfileRegistry,18)19from langchain_core.language_models.chat_models import (20    BaseChatModel,21    agenerate_from_stream,22    generate_from_stream,23)24from langchain_core.messages import (25    AIMessage,26    AIMessageChunk,27    BaseMessage,28    BaseMessageChunk,29    ChatMessage,30    ChatMessageChunk,31    FunctionMessageChunk,32    HumanMessage,33    HumanMessageChunk,34    SystemMessage,35    SystemMessageChunk,36    ToolMessageChunk,37)38from langchain_core.messages.ai import (39    OutputTokenDetails,40    UsageMetadata,41    subtract_usage,42)43from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult44from langchain_core.runnables import Runnable, RunnableMap, RunnablePassthrough45from langchain_core.utils import get_pydantic_field_names, secret_from_env46from langchain_core.utils.function_calling import convert_to_json_schema47from langchain_core.utils.pydantic import is_basemodel_subclass48from perplexity import AsyncPerplexity, Perplexity49from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator50from typing_extensions import Self5152from langchain_perplexity.data._profiles import _PROFILES53from langchain_perplexity.output_parsers import (54    ReasoningJsonOutputParser,55    ReasoningStructuredOutputParser,56)57from langchain_perplexity.types import MediaResponse, WebSearchOptions5859_DictOrPydanticClass: TypeAlias = dict[str, Any] | type[BaseModel]60_DictOrPydantic: TypeAlias = dict | BaseModel6162logger = logging.getLogger(__name__)636465_MODEL_PROFILES = cast("ModelProfileRegistry", _PROFILES)666768def _get_default_model_profile(model_name: str) -> ModelProfile:69    default = _MODEL_PROFILES.get(model_name) or {}70    return default.copy()717273def _is_pydantic_class(obj: Any) -> bool:74    return isinstance(obj, type) and is_basemodel_subclass(obj)757677def _create_usage_metadata(token_usage: dict) -> UsageMetadata:78    """Create UsageMetadata from Perplexity token usage data.7980    Args:81        token_usage: Dictionary containing token usage information from Perplexity API.8283    Returns:84        UsageMetadata with properly structured token counts and details.85    """86    input_tokens = token_usage.get("prompt_tokens", 0)87    output_tokens = token_usage.get("completion_tokens", 0)88    total_tokens = token_usage.get("total_tokens", input_tokens + output_tokens)8990    # Build output_token_details for Perplexity-specific fields91    output_token_details: OutputTokenDetails = {}92    if (reasoning := token_usage.get("reasoning_tokens")) is not None:93        output_token_details["reasoning"] = reasoning94    if (citation_tokens := token_usage.get("citation_tokens")) is not None:95        output_token_details["citation_tokens"] = citation_tokens  # type: ignore[typeddict-unknown-key]9697    return UsageMetadata(98        input_tokens=input_tokens,99        output_tokens=output_tokens,100        total_tokens=total_tokens,101        output_token_details=output_token_details,102    )103104105class ChatPerplexity(BaseChatModel):106    """`Perplexity AI` Chat models API.107108    Setup:109        To use, you should have the environment variable `PPLX_API_KEY` set to your API key.110        Any parameters that are valid to be passed to the perplexity.create call111        can be passed in, even if not explicitly saved on this class.112113        ```bash114        export PPLX_API_KEY=your_api_key115        ```116117        Key init args - completion params:118            model:119                Name of the model to use. e.g. "sonar"120            temperature:121                Sampling temperature to use.122            max_tokens:123                Maximum number of tokens to generate.124            streaming:125                Whether to stream the results or not.126127        Key init args - client params:128            pplx_api_key:129                API key for PerplexityChat API.130            request_timeout:131                Timeout for requests to PerplexityChat completion API.132            max_retries:133                Maximum number of retries to make when generating.134135        See full list of supported init args and their descriptions in the params section.136137        Instantiate:138139        ```python140        from langchain_perplexity import ChatPerplexity141142        model = ChatPerplexity(model="sonar", temperature=0.7)143        ```144145        Invoke:146147        ```python148        messages = [("system", "You are a chatbot."), ("user", "Hello!")]149        model.invoke(messages)150        ```151152        Invoke with structured output:153154        ```python155        from pydantic import BaseModel156157158        class StructuredOutput(BaseModel):159            role: str160            content: str161162163        model.with_structured_output(StructuredOutput)164        model.invoke(messages)165        ```166167        Stream:168        ```python169        for chunk in model.stream(messages):170            print(chunk.content)171        ```172173        Token usage:174        ```python175        response = model.invoke(messages)176        response.usage_metadata177        ```178179        Response metadata:180        ```python181        response = model.invoke(messages)182        response.response_metadata183        ```184    """  # noqa: E501185186    client: Any = Field(default=None, exclude=True)187    async_client: Any = Field(default=None, exclude=True)188189    model: str = "sonar"190    """Model name."""191192    temperature: float = 0.7193    """What sampling temperature to use."""194195    model_kwargs: dict[str, Any] = Field(default_factory=dict)196    """Holds any model parameters valid for `create` call not explicitly specified."""197198    pplx_api_key: SecretStr | None = Field(199        default_factory=secret_from_env("PPLX_API_KEY", default=None), alias="api_key"200    )201    """Perplexity API key."""202203    request_timeout: float | tuple[float, float] | None = Field(None, alias="timeout")204    """Timeout for requests to PerplexityChat completion API."""205206    max_retries: int = 6207    """Maximum number of retries to make when generating."""208209    streaming: bool = False210    """Whether to stream the results or not."""211212    max_tokens: int | None = None213    """Maximum number of tokens to generate."""214215    search_mode: Literal["academic", "sec", "web"] | None = None216    """Search mode for specialized content: "academic", "sec", or "web"."""217218    reasoning_effort: Literal["low", "medium", "high"] | None = None219    """Reasoning effort: "low", "medium", or "high" (default)."""220221    language_preference: str | None = None222    """Language preference:"""223224    search_domain_filter: list[str] | None = None225    """Search domain filter: list of domains to filter search results (max 20)."""226227    return_images: bool = False228    """Whether to return images in the response."""229230    return_related_questions: bool = False231    """Whether to return related questions in the response."""232233    search_recency_filter: Literal["day", "week", "month", "year"] | None = None234    """Filter search results by recency: "day", "week", "month", or "year"."""235236    search_after_date_filter: str | None = None237    """Search after date filter: date in format "MM/DD/YYYY" (default)."""238239    search_before_date_filter: str | None = None240    """Only return results before this date (format: MM/DD/YYYY)."""241242    last_updated_after_filter: str | None = None243    """Only return results updated after this date (format: MM/DD/YYYY)."""244245    last_updated_before_filter: str | None = None246    """Only return results updated before this date (format: MM/DD/YYYY)."""247248    disable_search: bool = False249    """Whether to disable web search entirely."""250251    enable_search_classifier: bool = False252    """Whether to enable the search classifier."""253254    web_search_options: WebSearchOptions | None = None255    """Configuration for web search behavior including Pro Search."""256257    media_response: MediaResponse | None = None258    """Media response: "images", "videos", or "none" (default)."""259260    model_config = ConfigDict(populate_by_name=True)261262    @property263    def lc_secrets(self) -> dict[str, str]:264        return {"pplx_api_key": "PPLX_API_KEY"}265266    @model_validator(mode="before")267    @classmethod268    def build_extra(cls, values: dict[str, Any]) -> Any:269        """Build extra kwargs from additional params that were passed in."""270        all_required_field_names = get_pydantic_field_names(cls)271        extra = values.get("model_kwargs", {})272        for field_name in list(values):273            if field_name in extra:274                raise ValueError(f"Found {field_name} supplied twice.")275            if field_name not in all_required_field_names:276                logger.warning(277                    f"""WARNING! {field_name} is not a default parameter.278                    {field_name} was transferred to model_kwargs.279                    Please confirm that {field_name} is what you intended."""280                )281                extra[field_name] = values.pop(field_name)282283        invalid_model_kwargs = all_required_field_names.intersection(extra.keys())284        if invalid_model_kwargs:285            raise ValueError(286                f"Parameters {invalid_model_kwargs} should be specified explicitly. "287                f"Instead they were passed in as part of `model_kwargs` parameter."288            )289290        values["model_kwargs"] = extra291        return values292293    @model_validator(mode="after")294    def validate_environment(self) -> Self:295        """Validate that api key and python package exists in environment."""296        pplx_api_key = (297            self.pplx_api_key.get_secret_value() if self.pplx_api_key else None298        )299300        client_params: dict[str, Any] = {301            "api_key": pplx_api_key,302            "max_retries": self.max_retries,303        }304        if self.request_timeout is not None:305            client_params["timeout"] = self.request_timeout306307        if not self.client:308            self.client = Perplexity(**client_params)309310        if not self.async_client:311            self.async_client = AsyncPerplexity(**client_params)312313        return self314315    def _resolve_model_profile(self) -> ModelProfile | None:316        return _get_default_model_profile(self.model) or None317318    @property319    def _default_params(self) -> dict[str, Any]:320        """Get the default parameters for calling PerplexityChat API."""321        params: dict[str, Any] = {322            "max_tokens": self.max_tokens,323            "stream": self.streaming,324            "temperature": self.temperature,325        }326        if self.search_mode:327            params["search_mode"] = self.search_mode328        if self.reasoning_effort:329            params["reasoning_effort"] = self.reasoning_effort330        if self.language_preference:331            params["language_preference"] = self.language_preference332        if self.search_domain_filter:333            params["search_domain_filter"] = self.search_domain_filter334        if self.return_images:335            params["return_images"] = self.return_images336        if self.return_related_questions:337            params["return_related_questions"] = self.return_related_questions338        if self.search_recency_filter:339            params["search_recency_filter"] = self.search_recency_filter340        if self.search_after_date_filter:341            params["search_after_date_filter"] = self.search_after_date_filter342        if self.search_before_date_filter:343            params["search_before_date_filter"] = self.search_before_date_filter344        if self.last_updated_after_filter:345            params["last_updated_after_filter"] = self.last_updated_after_filter346        if self.last_updated_before_filter:347            params["last_updated_before_filter"] = self.last_updated_before_filter348        if self.disable_search:349            params["disable_search"] = self.disable_search350        if self.enable_search_classifier:351            params["enable_search_classifier"] = self.enable_search_classifier352        if self.web_search_options:353            params["web_search_options"] = self.web_search_options.model_dump(354                exclude_none=True355            )356        if self.media_response:357            if "extra_body" not in params:358                params["extra_body"] = {}359            params["extra_body"]["media_response"] = self.media_response.model_dump(360                exclude_none=True361            )362363        return {**params, **self.model_kwargs}364365    def _convert_message_to_dict(self, message: BaseMessage) -> dict[str, Any]:366        if isinstance(message, ChatMessage):367            message_dict = {"role": message.role, "content": message.content}368        elif isinstance(message, SystemMessage):369            message_dict = {"role": "system", "content": message.content}370        elif isinstance(message, HumanMessage):371            message_dict = {"role": "user", "content": message.content}372        elif isinstance(message, AIMessage):373            message_dict = {"role": "assistant", "content": message.content}374        else:375            raise TypeError(f"Got unknown type {message}")376        return message_dict377378    def _create_message_dicts(379        self, messages: list[BaseMessage], stop: list[str] | None380    ) -> tuple[list[dict[str, Any]], dict[str, Any]]:381        params = dict(self._invocation_params)382        if stop is not None:383            if "stop" in params:384                raise ValueError("`stop` found in both the input and default params.")385            params["stop"] = stop386        message_dicts = [self._convert_message_to_dict(m) for m in messages]387        return message_dicts, params388389    def _convert_delta_to_message_chunk(390        self, _dict: Mapping[str, Any], default_class: type[BaseMessageChunk]391    ) -> BaseMessageChunk:392        role = _dict.get("role")393        content = _dict.get("content") or ""394        additional_kwargs: dict = {}395        if _dict.get("function_call"):396            function_call = dict(_dict["function_call"])397            if "name" in function_call and function_call["name"] is None:398                function_call["name"] = ""399            additional_kwargs["function_call"] = function_call400        if _dict.get("tool_calls"):401            additional_kwargs["tool_calls"] = _dict["tool_calls"]402403        if role == "user" or default_class == HumanMessageChunk:404            return HumanMessageChunk(content=content)405        elif role == "assistant" or default_class == AIMessageChunk:406            return AIMessageChunk(content=content, additional_kwargs=additional_kwargs)407        elif role == "system" or default_class == SystemMessageChunk:408            return SystemMessageChunk(content=content)409        elif role == "function" or default_class == FunctionMessageChunk:410            return FunctionMessageChunk(content=content, name=_dict["name"])411        elif role == "tool" or default_class == ToolMessageChunk:412            return ToolMessageChunk(content=content, tool_call_id=_dict["tool_call_id"])413        elif role or default_class == ChatMessageChunk:414            return ChatMessageChunk(content=content, role=role)  # type: ignore[arg-type]415        else:416            return default_class(content=content)  # type: ignore[call-arg]417418    def _stream(419        self,420        messages: list[BaseMessage],421        stop: list[str] | None = None,422        run_manager: CallbackManagerForLLMRun | None = None,423        **kwargs: Any,424    ) -> Iterator[ChatGenerationChunk]:425        message_dicts, params = self._create_message_dicts(messages, stop)426        params = {**params, **kwargs}427        default_chunk_class = AIMessageChunk428        params.pop("stream", None)429        if stop:430            params["stop_sequences"] = stop431        stream_resp = self.client.chat.completions.create(432            messages=message_dicts, stream=True, **params433        )434        first_chunk = True435        prev_total_usage: UsageMetadata | None = None436437        added_model_name: bool = False438        added_search_queries: bool = False439        added_search_context_size: bool = False440        for chunk in stream_resp:441            if not isinstance(chunk, dict):442                chunk = chunk.model_dump()443            # Collect standard usage metadata (transform from aggregate to delta)444            if total_usage := chunk.get("usage"):445                lc_total_usage = _create_usage_metadata(total_usage)446                if prev_total_usage:447                    usage_metadata: UsageMetadata | None = subtract_usage(448                        lc_total_usage, prev_total_usage449                    )450                else:451                    usage_metadata = lc_total_usage452                prev_total_usage = lc_total_usage453            else:454                usage_metadata = None455            generation_info = {}456            if (model_name := chunk.get("model")) and not added_model_name:457                generation_info["model_name"] = model_name458                added_model_name = True459            if total_usage := chunk.get("usage"):460                if num_search_queries := total_usage.get("num_search_queries"):461                    if not added_search_queries:462                        generation_info["num_search_queries"] = num_search_queries463                        added_search_queries = True464                if not added_search_context_size:465                    if search_context_size := total_usage.get("search_context_size"):466                        generation_info["search_context_size"] = search_context_size467                        added_search_context_size = True468469            choices = chunk.get("choices") or []470            if len(choices) == 0:471                # Usage-only or otherwise empty chunk: still yield so the stream472                # is never empty and downstream callers receive usage metadata.473                message = AIMessageChunk(content="", usage_metadata=usage_metadata)474                yield ChatGenerationChunk(475                    message=message, generation_info=generation_info or None476                )477                continue478            choice = choices[0]479480            additional_kwargs = {}481            if first_chunk:482                additional_kwargs["citations"] = chunk.get("citations", [])483                for attr in ["images", "related_questions", "search_results"]:484                    if attr in chunk:485                        additional_kwargs[attr] = chunk[attr]486487                if chunk.get("videos"):488                    additional_kwargs["videos"] = chunk["videos"]489490                if chunk.get("reasoning_steps"):491                    additional_kwargs["reasoning_steps"] = chunk["reasoning_steps"]492493            chunk = self._convert_delta_to_message_chunk(494                choice["delta"], default_chunk_class495            )496497            if isinstance(chunk, AIMessageChunk) and usage_metadata:498                chunk.usage_metadata = usage_metadata499500            if first_chunk:501                chunk.additional_kwargs |= additional_kwargs502                first_chunk = False503504            if finish_reason := choice.get("finish_reason"):505                generation_info["finish_reason"] = finish_reason506507            default_chunk_class = chunk.__class__508            chunk = ChatGenerationChunk(message=chunk, generation_info=generation_info)509            if run_manager:510                run_manager.on_llm_new_token(chunk.text, chunk=chunk)511            yield chunk512513    async def _astream(514        self,515        messages: list[BaseMessage],516        stop: list[str] | None = None,517        run_manager: AsyncCallbackManagerForLLMRun | None = None,518        **kwargs: Any,519    ) -> AsyncIterator[ChatGenerationChunk]:520        message_dicts, params = self._create_message_dicts(messages, stop)521        params = {**params, **kwargs}522        default_chunk_class = AIMessageChunk523        params.pop("stream", None)524        if stop:525            params["stop_sequences"] = stop526        stream_resp = await self.async_client.chat.completions.create(527            messages=message_dicts, stream=True, **params528        )529        first_chunk = True530        prev_total_usage: UsageMetadata | None = None531532        added_model_name: bool = False533        added_search_queries: bool = False534        async for chunk in stream_resp:535            if not isinstance(chunk, dict):536                chunk = chunk.model_dump()537            if total_usage := chunk.get("usage"):538                lc_total_usage = _create_usage_metadata(total_usage)539                if prev_total_usage:540                    usage_metadata: UsageMetadata | None = subtract_usage(541                        lc_total_usage, prev_total_usage542                    )543                else:544                    usage_metadata = lc_total_usage545                prev_total_usage = lc_total_usage546            else:547                usage_metadata = None548            generation_info = {}549            if (model_name := chunk.get("model")) and not added_model_name:550                generation_info["model_name"] = model_name551                added_model_name = True552            if total_usage := chunk.get("usage"):553                if num_search_queries := total_usage.get("num_search_queries"):554                    if not added_search_queries:555                        generation_info["num_search_queries"] = num_search_queries556                        added_search_queries = True557                if search_context_size := total_usage.get("search_context_size"):558                    generation_info["search_context_size"] = search_context_size559560            choices = chunk.get("choices") or []561            if len(choices) == 0:562                # Usage-only or otherwise empty chunk: still yield so the stream563                # is never empty and downstream callers receive usage metadata.564                message = AIMessageChunk(content="", usage_metadata=usage_metadata)565                yield ChatGenerationChunk(566                    message=message, generation_info=generation_info or None567                )568                continue569            choice = choices[0]570571            additional_kwargs = {}572            if first_chunk:573                additional_kwargs["citations"] = chunk.get("citations", [])574                for attr in ["images", "related_questions", "search_results"]:575                    if attr in chunk:576                        additional_kwargs[attr] = chunk[attr]577578                if chunk.get("videos"):579                    additional_kwargs["videos"] = chunk["videos"]580581                if chunk.get("reasoning_steps"):582                    additional_kwargs["reasoning_steps"] = chunk["reasoning_steps"]583584            chunk = self._convert_delta_to_message_chunk(585                choice["delta"], default_chunk_class586            )587588            if isinstance(chunk, AIMessageChunk) and usage_metadata:589                chunk.usage_metadata = usage_metadata590591            if first_chunk:592                chunk.additional_kwargs |= additional_kwargs593                first_chunk = False594595            if finish_reason := choice.get("finish_reason"):596                generation_info["finish_reason"] = finish_reason597598            default_chunk_class = chunk.__class__599            chunk = ChatGenerationChunk(message=chunk, generation_info=generation_info)600            if run_manager:601                await run_manager.on_llm_new_token(chunk.text, chunk=chunk)602            yield chunk603604    def _generate(605        self,606        messages: list[BaseMessage],607        stop: list[str] | None = None,608        run_manager: CallbackManagerForLLMRun | None = None,609        **kwargs: Any,610    ) -> ChatResult:611        if self.streaming:612            stream_iter = self._stream(613                messages, stop=stop, run_manager=run_manager, **kwargs614            )615            if stream_iter:616                return generate_from_stream(stream_iter)617        message_dicts, params = self._create_message_dicts(messages, stop)618        params = {**params, **kwargs}619        response = self.client.chat.completions.create(messages=message_dicts, **params)620621        if hasattr(response, "usage") and response.usage:622            usage_dict = response.usage.model_dump()623            usage_metadata = _create_usage_metadata(usage_dict)624        else:625            usage_metadata = None626            usage_dict = {}627628        additional_kwargs = {}629        for attr in ["citations", "images", "related_questions", "search_results"]:630            if hasattr(response, attr) and getattr(response, attr):631                additional_kwargs[attr] = getattr(response, attr)632633        if hasattr(response, "videos") and response.videos:634            additional_kwargs["videos"] = [635                v.model_dump() if hasattr(v, "model_dump") else v636                for v in response.videos637            ]638639        if hasattr(response, "reasoning_steps") and response.reasoning_steps:640            additional_kwargs["reasoning_steps"] = [641                r.model_dump() if hasattr(r, "model_dump") else r642                for r in response.reasoning_steps643            ]644645        response_metadata: dict[str, Any] = {646            "model_name": getattr(response, "model", self.model)647        }648        if num_search_queries := usage_dict.get("num_search_queries"):649            response_metadata["num_search_queries"] = num_search_queries650        if search_context_size := usage_dict.get("search_context_size"):651            response_metadata["search_context_size"] = search_context_size652653        message = AIMessage(654            content=response.choices[0].message.content,655            additional_kwargs=additional_kwargs,656            usage_metadata=usage_metadata,657            response_metadata=response_metadata,658        )659        return ChatResult(generations=[ChatGeneration(message=message)])660661    async def _agenerate(662        self,663        messages: list[BaseMessage],664        stop: list[str] | None = None,665        run_manager: AsyncCallbackManagerForLLMRun | None = None,666        **kwargs: Any,667    ) -> ChatResult:668        if self.streaming:669            stream_iter = self._astream(670                messages, stop=stop, run_manager=run_manager, **kwargs671            )672            if stream_iter:673                return await agenerate_from_stream(stream_iter)674        message_dicts, params = self._create_message_dicts(messages, stop)675        params = {**params, **kwargs}676        response = await self.async_client.chat.completions.create(677            messages=message_dicts, **params678        )679680        if hasattr(response, "usage") and response.usage:681            usage_dict = response.usage.model_dump()682            usage_metadata = _create_usage_metadata(usage_dict)683        else:684            usage_metadata = None685            usage_dict = {}686687        additional_kwargs = {}688        for attr in ["citations", "images", "related_questions", "search_results"]:689            if hasattr(response, attr) and getattr(response, attr):690                additional_kwargs[attr] = getattr(response, attr)691692        if hasattr(response, "videos") and response.videos:693            additional_kwargs["videos"] = [694                v.model_dump() if hasattr(v, "model_dump") else v695                for v in response.videos696            ]697698        if hasattr(response, "reasoning_steps") and response.reasoning_steps:699            additional_kwargs["reasoning_steps"] = [700                r.model_dump() if hasattr(r, "model_dump") else r701                for r in response.reasoning_steps702            ]703704        response_metadata: dict[str, Any] = {705            "model_name": getattr(response, "model", self.model)706        }707        if num_search_queries := usage_dict.get("num_search_queries"):708            response_metadata["num_search_queries"] = num_search_queries709        if search_context_size := usage_dict.get("search_context_size"):710            response_metadata["search_context_size"] = search_context_size711712        message = AIMessage(713            content=response.choices[0].message.content,714            additional_kwargs=additional_kwargs,715            usage_metadata=usage_metadata,716            response_metadata=response_metadata,717        )718        return ChatResult(generations=[ChatGeneration(message=message)])719720    @property721    def _invocation_params(self) -> Mapping[str, Any]:722        """Get the parameters used to invoke the model."""723        pplx_creds: dict[str, Any] = {"model": self.model}724        return {**pplx_creds, **self._default_params}725726    @property727    def _llm_type(self) -> str:728        """Return type of chat model."""729        return "perplexitychat"730731    def with_structured_output(732        self,733        schema: _DictOrPydanticClass | None = None,734        *,735        method: Literal["json_schema"] = "json_schema",736        include_raw: bool = False,737        strict: bool | None = None,738        **kwargs: Any,739    ) -> Runnable[LanguageModelInput, _DictOrPydantic]:740        """Model wrapper that returns outputs formatted to match the given schema for Preplexity.741        Currently, Perplexity only supports "json_schema" method for structured output742        as per their [official documentation](https://docs.perplexity.ai/guides/structured-outputs).743744        Args:745            schema: The output schema. Can be passed in as:746747                - a JSON Schema,748                - a `TypedDict` class,749                - or a Pydantic class750751            method: The method for steering model generation, currently only support:752753                - `'json_schema'`: Use the JSON Schema to parse the model output754755756            include_raw:757                If `False` then only the parsed structured output is returned.758759                If an error occurs during model output parsing it will be raised.760761                If `True` then both the raw model response (a `BaseMessage`) and the762                parsed model response will be returned.763764                If an error occurs during output parsing it will be caught and returned765                as well.766767                The final output is always a `dict` with keys `'raw'`, `'parsed'`, and768                `'parsing_error'`.769            strict:770                Unsupported: whether to enable strict schema adherence when generating771                the output. This parameter is included for compatibility with other772                chat models, but is currently ignored.773774            kwargs: Additional keyword args aren't supported.775776        Returns:777            A `Runnable` that takes same inputs as a778                `langchain_core.language_models.chat.BaseChatModel`. If `include_raw` is779                `False` and `schema` is a Pydantic class, `Runnable` outputs an instance780                of `schema` (i.e., a Pydantic object). Otherwise, if `include_raw` is781                `False` then `Runnable` outputs a `dict`.782783                If `include_raw` is `True`, then `Runnable` outputs a `dict` with keys:784785                - `'raw'`: `BaseMessage`786                - `'parsed'`: `None` if there was a parsing error, otherwise the type787                    depends on the `schema` as described above.788                - `'parsing_error'`: `BaseException | None`789        """  # noqa: E501790        if method in ("function_calling", "json_mode"):791            method = "json_schema"792        if method == "json_schema":793            if schema is None:794                raise ValueError(795                    "schema must be specified when method is not 'json_schema'. "796                    "Received None."797                )798            is_pydantic_schema = _is_pydantic_class(schema)799            response_format = convert_to_json_schema(schema)800            llm = self.bind(801                response_format={802                    "type": "json_schema",803                    "json_schema": {"schema": response_format},804                },805                ls_structured_output_format={806                    "kwargs": {"method": method},807                    "schema": response_format,808                },809            )810            output_parser = (811                ReasoningStructuredOutputParser(pydantic_object=schema)  # type: ignore[arg-type]812                if is_pydantic_schema813                else ReasoningJsonOutputParser()814            )815        else:816            raise ValueError(817                f"Unrecognized method argument. Expected 'json_schema' Received:\818                    '{method}'"819            )820821        if include_raw:822            parser_assign = RunnablePassthrough.assign(823                parsed=itemgetter("raw") | output_parser, parsing_error=lambda _: None824            )825            parser_none = RunnablePassthrough.assign(parsed=lambda _: None)826            parser_with_fallback = parser_assign.with_fallbacks(827                [parser_none], exception_key="parsing_error"828            )829            return RunnableMap(raw=llm) | parser_with_fallback830        else:831            return llm | output_parser

Code quality findings 13

Overuse may indicate design issues; consider polymorphism
isinstance-overuse
return isinstance(obj, type) and is_basemodel_subclass(obj)
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
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 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.