Current section

Files

Jump to
langchain lib chat_models chat_perplexity.ex
Raw

lib/chat_models/chat_perplexity.ex

defmodule LangChain.ChatModels.ChatPerplexity do
@moduledoc """
Represents the [Perplexity Chat model](https://docs.perplexity.ai/api-reference/chat-completions).
This module implements a client for the Perplexity Chat API, providing functions to validate input parameters,
format API requests, and parse API responses into LangChain's structured data types.
Perplexity does not natively support tool calling in the same manner as some other chat models.
To overcome this limitation, this module employs a workaround using structured outputs via a JSON schema.
When tools are provided, the API request is augmented with a JSON schema that defines the expected format
for tool calls. The response processing logic then detects and decodes these tool call details, converting them
into corresponding ToolCall structs. This approach allows LangChain to seamlessly emulate tool calling functionality
and integrate it with its standard workflow, similar to how ChatOpenAI handles function calls.
In addition, this module supports various configuration options such as temperature, top_p, top_k,
and streaming, as well as callbacks for token usage and new message events.
Overall, this implementation provides a unified interface for interacting with the Perplexity Chat API
while working around its limitations regarding tool calling.
## Tool Calls
In order to use tool calls, you need specifically prompt Perplexity as outlined in their
[Prompt Guide](https://docs.perplexity.ai/guides/prompt-guide) as well as the
[Structured Outputs Guide](https://docs.perplexity.ai/guides/structured-outputs).
Provide it additional prompting like:
```
Rules:
1. Provide only the final answer. It is important that you do not include any explanation on the steps below.
2. Do not show the intermediate steps information.
Output a JSON object with the following fields:
- title: The article title
- keywords: An array of SEO keywords
- meta_description: The SEO meta description
```
## Connection Retry Behavior
The `retry_count` option controls how many times a request is retried when
a pooled HTTP connection turns out to be stale (server closed it between
requests). This is a transport-level issue where retrying with a fresh
connection is the correct response.
**Only closed-connection errors are retried.** Timeouts, rate limits (429),
overloaded (529), authentication errors, and invalid requests all return
immediately -- they are not problems that a simple retry will fix.
| `retry_count` | Total HTTP requests |
|---|---|
| `0` | 1 (no retries) |
| `1` | 2 (1 initial + 1 retry) |
| `2` (default) | 3 (1 initial + 2 retries) |
Req's built-in HTTP retry is disabled to prevent the two retry layers from
compounding. See [GitHub issue #503](https://github.com/brainlid/langchain/issues/503).
When running LLM calls from a background job queue (e.g., Oban) that has its
own retry logic, set `retry_count: 0` so there are no hidden retries:
ChatPerplexity.new!(%{model: "...", retry_count: 0})
"""
use Ecto.Schema
require Logger
import Ecto.Changeset
alias __MODULE__
alias LangChain.Config
alias LangChain.ChatModels.ChatModel
alias LangChain.Message
alias LangChain.MessageDelta
alias LangChain.Message.Citation
alias LangChain.Message.ContentPart
alias LangChain.Message.ToolCall
alias LangChain.TokenUsage
alias LangChain.LangChainError
alias LangChain.Utils
alias LangChain.Callbacks
alias LangChain.Telemetry
@behaviour ChatModel
@current_config_version 1
# Default endpoint for Perplexity API
@default_endpoint "https://api.perplexity.ai/chat/completions"
# Default timeout of 1 minute
@receive_timeout 60_000
@primary_key false
embedded_schema do
field :endpoint, :string, default: @default_endpoint
field :model, :string, default: "sonar-reasoning-pro"
field :api_key, :string
# What sampling temperature to use, between 0 and 2.
# Higher values make output more random, lower values more deterministic.
field :temperature, :float, default: 0.2
# The nucleus sampling threshold, between 0 and 1.
field :top_p, :float, default: 0.9
# The number of tokens for highest top-k filtering (0-2048).
field :top_k, :integer, default: 0
# Maximum number of tokens to generate
field :max_tokens, :integer
# Whether to stream the response
field :stream, :boolean, default: false
# Presence penalty between -2.0 and 2.0
field :presence_penalty, :float, default: 0.0
# Frequency penalty greater than 0
field :frequency_penalty, :float, default: 1.0
# Search domain filter for limiting citations
field :search_domain_filter, {:array, :string}
# Whether to return images in response
field :return_images, :boolean, default: false
# Whether to return related questions
field :return_related_questions, :boolean, default: false
# Time interval for search recency
field :search_recency_filter, :string
# Response format for structured outputs
field :response_format, :map
# Duration in seconds for response timeout
field :receive_timeout, :integer, default: @receive_timeout
# A list of maps for callback handlers
field :callbacks, {:array, :map}, default: []
# For help with debugging. It outputs the RAW Req response received and the
# RAW Elixir map being submitted to the API.
field :verbose_api, :boolean, default: false
# Number of retries on closed-connection errors (stale pool). The initial
# request always runs; this controls additional attempts only.
field :retry_count, :integer, default: 2
end
@type t :: %ChatPerplexity{}
@create_fields [
:endpoint,
:model,
:api_key,
:temperature,
:top_p,
:top_k,
:max_tokens,
:stream,
:presence_penalty,
:frequency_penalty,
:search_domain_filter,
:return_images,
:return_related_questions,
:search_recency_filter,
:response_format,
:receive_timeout,
:verbose_api,
:retry_count
]
@required_fields [:model]
@spec get_api_key(t()) :: String.t()
defp get_api_key(%ChatPerplexity{api_key: api_key}) do
api_key || Config.resolve(:perplexity_key, "")
end
@doc """
Setup a ChatPerplexity client configuration.
"""
@spec new(attrs :: map()) :: {:ok, t} | {:error, Ecto.Changeset.t()}
def new(%{} = attrs \\ %{}) do
%ChatPerplexity{}
|> cast(attrs, @create_fields)
|> common_validation()
|> apply_action(:insert)
end
@doc """
Setup a ChatPerplexity client configuration and return it or raise an error if invalid.
"""
@spec new!(attrs :: map()) :: t() | no_return()
def new!(attrs \\ %{}) do
case new(attrs) do
{:ok, chain} ->
chain
{:error, changeset} ->
raise LangChainError, changeset
end
end
defp common_validation(changeset) do
changeset
|> validate_required(@required_fields)
|> validate_number(:temperature, greater_than_or_equal_to: 0, less_than: 2)
|> validate_number(:top_p, greater_than: 0, less_than_or_equal_to: 1)
|> validate_number(:top_k, greater_than_or_equal_to: 0, less_than_or_equal_to: 2048)
|> validate_number(:presence_penalty, greater_than_or_equal_to: -2, less_than_or_equal_to: 2)
|> validate_number(:frequency_penalty, greater_than: 0)
|> validate_number(:receive_timeout, greater_than_or_equal_to: 0)
end
@doc """
Return the params formatted for an API request.
"""
@spec for_api(t(), [Message.t()], ChatModel.tools()) :: %{atom() => any()}
def for_api(%ChatPerplexity{} = perplexity, messages, tools) do
response_format =
if tools && !Enum.empty?(tools) do
%{
"type" => "json_schema",
"json_schema" => %{
"schema" => %{
"type" => "object",
"required" => ["tool_calls"],
"properties" => %{
"tool_calls" => %{
"type" => "array",
"items" => %{
"type" => "object",
"required" => ["name", "arguments"],
"properties" => %{
"name" => %{
"type" => "string",
"enum" => Enum.map(tools, & &1.name)
},
"arguments" => build_arguments_schema(tools)
}
}
}
}
}
}
}
else
perplexity.response_format
end
%{
model: perplexity.model,
messages: Enum.map(messages, &for_api(perplexity, &1)),
temperature: perplexity.temperature,
top_p: perplexity.top_p,
top_k: perplexity.top_k,
stream: perplexity.stream
}
|> Utils.conditionally_add_to_map(:max_tokens, perplexity.max_tokens)
|> Utils.conditionally_add_to_map(:presence_penalty, perplexity.presence_penalty)
|> Utils.conditionally_add_to_map(:frequency_penalty, perplexity.frequency_penalty)
|> Utils.conditionally_add_to_map(:search_domain_filter, perplexity.search_domain_filter)
|> Utils.conditionally_add_to_map(:return_images, perplexity.return_images)
|> Utils.conditionally_add_to_map(
:return_related_questions,
perplexity.return_related_questions
)
|> Utils.conditionally_add_to_map(:search_recency_filter, perplexity.search_recency_filter)
|> Utils.conditionally_add_to_map(:response_format, response_format)
end
defp build_arguments_schema([tool | _]) do
properties =
tool.parameters
|> Enum.map(fn param ->
{param.name, param_to_json_schema(param)}
end)
|> Map.new()
required =
tool.parameters
|> Enum.filter(& &1.required)
|> Enum.map(& &1.name)
%{
"type" => "object",
"required" => required,
"properties" => properties
}
end
defp param_to_json_schema(param) do
base = %{"type" => atom_to_json_type(param.type)}
base
|> add_enum(param)
|> add_items(param)
end
defp atom_to_json_type(:string), do: "string"
defp atom_to_json_type(:number), do: "number"
defp atom_to_json_type(:integer), do: "integer"
defp atom_to_json_type(:boolean), do: "boolean"
defp atom_to_json_type(:array), do: "array"
defp atom_to_json_type(:object), do: "object"
defp add_enum(schema, %{enum: enum}) when enum in [nil, []], do: schema
defp add_enum(schema, %{enum: enum}), do: Map.put(schema, "enum", enum)
defp add_items(schema, %{type: :array, item_type: item_type}) do
Map.put(schema, "items", %{"type" => atom_to_json_type(String.to_existing_atom(item_type))})
end
defp add_items(schema, _), do: schema
@doc """
Convert a LangChain Message-based structure to the expected map of data for
the Perplexity API.
"""
@spec for_api(t(), Message.t()) :: %{String.t() => any()}
def for_api(%ChatPerplexity{}, %Message{} = msg) do
content =
case msg.content do
content when is_binary(content) -> content
content when is_list(content) -> ContentPart.parts_to_string(content)
nil -> nil
end
%{
"role" => msg.role,
"content" => content
}
end
@impl ChatModel
def call(perplexity, prompt, tools \\ [])
def call(%ChatPerplexity{} = perplexity, prompt, tools) when is_binary(prompt) do
messages = [
Message.new_system!(),
Message.new_user!(prompt)
]
call(perplexity, messages, tools)
end
def call(%ChatPerplexity{} = perplexity, messages, tools) when is_list(messages) do
metadata = %{
model: perplexity.model,
message_count: length(messages),
tools_count: length(tools)
}
Telemetry.span([:langchain, :llm, :call], metadata, fn ->
try do
# Track the prompt being sent
Telemetry.llm_prompt(
%{system_time: System.system_time()},
%{model: perplexity.model, messages: messages}
)
case do_api_request(perplexity, messages, tools) do
{:error, reason} ->
{:error, reason}
{:ok, %Req.Response{body: body}} when is_list(body) ->
# Streaming response - body contains accumulated MessageDelta lists
Telemetry.llm_response(
%{system_time: System.system_time()},
%{model: perplexity.model, response: body}
)
{:ok, body}
parsed_data ->
# Non-streaming response
Telemetry.llm_response(
%{system_time: System.system_time()},
%{model: perplexity.model, response: parsed_data}
)
{:ok, [parsed_data]}
end
rescue
err in LangChainError ->
{:error, err}
end
end)
end
@doc false
@spec do_api_request(t(), [Message.t()], ChatModel.tools(), integer()) ::
list() | struct() | {:error, LangChainError.t()}
def do_api_request(perplexity, messages, tools, retry_count \\ nil)
def do_api_request(_perplexity, _messages, _tools, 0) do
raise LangChainError, "Retries exceeded. Connection failed."
end
def do_api_request(
%ChatPerplexity{stream: false} = perplexity,
messages,
tools,
retry_count
) do
retry_count = retry_count || perplexity.retry_count + 1
req =
Req.new(
url: perplexity.endpoint,
json: for_api(perplexity, messages, tools),
auth: {:bearer, get_api_key(perplexity)},
receive_timeout: perplexity.receive_timeout
)
req
|> Req.post()
|> case do
{:ok, %Req.Response{body: data} = response} ->
Callbacks.fire(perplexity.callbacks, :on_llm_response_headers, [response.headers])
Callbacks.fire(perplexity.callbacks, :on_llm_token_usage, [
get_token_usage(data)
])
case do_process_response(perplexity, data, tools) do
{:error, %LangChainError{} = reason} ->
{:error, reason}
result ->
Callbacks.fire(perplexity.callbacks, :on_llm_new_message, [result])
# Track non-streaming response completion
Telemetry.emit_event(
[:langchain, :llm, :response, :non_streaming],
%{system_time: System.system_time()},
%{
model: perplexity.model,
response_size: byte_size(inspect(result))
}
)
result
end
{:error, %Req.TransportError{reason: :timeout} = err} ->
{:error,
LangChainError.exception(type: "timeout", message: "Request timed out", original: err)}
{:error, %Req.TransportError{reason: :closed}} ->
Logger.debug(fn -> "Connection closed: retry count = #{inspect(retry_count)}" end)
do_api_request(perplexity, messages, tools, retry_count - 1)
other ->
Logger.warning(fn -> "Unexpected and unhandled API response! #{inspect(other)}" end)
other
end
end
def do_api_request(
%ChatPerplexity{stream: true} = perplexity,
messages,
tools,
retry_count
) do
retry_count = retry_count || perplexity.retry_count + 1
Req.new(
url: perplexity.endpoint,
json: for_api(perplexity, messages, tools),
auth: {:bearer, get_api_key(perplexity)},
receive_timeout: perplexity.receive_timeout
)
|> Req.post(
into:
Utils.handle_stream_fn(
perplexity,
&decode_stream/1,
&process_stream_chunk(perplexity, &1)
)
)
|> case do
{:ok, response} ->
Callbacks.fire(perplexity.callbacks, :on_llm_response_headers, [response.headers])
{:ok, response}
{:error, %Req.TransportError{reason: :timeout} = err} ->
{:error,
LangChainError.exception(type: "timeout", message: "Request timed out", original: err)}
{:error, %Req.TransportError{reason: :closed}} ->
Logger.debug(fn -> "Connection closed: retry count = #{inspect(retry_count)}" end)
do_api_request(perplexity, messages, tools, retry_count - 1)
other ->
Logger.warning(fn -> "Unexpected and unhandled API response! #{inspect(other)}" end)
other
end
end
@doc """
Decode a streamed response from the Perplexity API.
"""
@spec decode_stream({String.t(), String.t()}) :: {%{String.t() => any()}}
def decode_stream({raw_data, buffer}, done \\ []) do
raw_data
|> String.split("data: ")
|> Enum.reduce({done, buffer}, fn str, {done, incomplete} = acc ->
str
|> String.trim()
|> case do
"" ->
acc
"[DONE]" ->
acc
json ->
parse_combined_data(incomplete, json, done)
end
end)
end
defp parse_combined_data("", json, done) do
json
|> Jason.decode()
|> case do
{:ok, parsed} ->
{done ++ [parsed], ""}
{:error, _reason} ->
{done, json}
end
end
defp parse_combined_data(incomplete, json, done) do
starting_json = incomplete <> json
decode_stream({starting_json, ""}, done)
end
@doc false
@spec do_process_response(t(), map(), ChatModel.tools()) ::
Message.t() | {:error, LangChainError.t()}
def do_process_response(model, %{"choices" => [choice | _]} = data, tools) do
# Fire token usage callback if present
if usage = Map.get(data, "usage") do
case get_token_usage(%{"usage" => usage}) do
%TokenUsage{} = token_usage ->
Callbacks.fire(model.callbacks, :on_llm_token_usage, [token_usage])
nil ->
:ok
end
end
# Process the first choice and attach citations from top-level response
model
|> do_process_response(choice, tools)
|> attach_perplexity_citations(data)
end
def do_process_response(
_model,
%{"finish_reason" => finish_reason, "message" => %{"content" => content, "role" => role}} =
data,
tools
)
when tools != [] do
status = finish_reason_to_status(finish_reason)
# Try to parse content as JSON since we expect structured output.
# Perplexity returns structured output matching the tool_calls JSON schema
# we send in for_api: {"tool_calls": [{"name": "...", "arguments": {...}}]}
case Jason.decode(content) do
{:ok, %{"tool_calls" => tool_calls}} when is_list(tool_calls) ->
parsed_tool_calls =
Enum.map(tool_calls, fn call ->
args_json = Jason.encode!(call["arguments"])
tool_call =
ToolCall.new!(%{
type: :function,
status: :complete,
name: call["name"],
arguments: args_json,
call_id: Ecto.UUID.generate()
})
# Force arguments back to JSON string (validate_and_parse_arguments
# decodes it to a map, but we want to keep the string form)
%{tool_call | arguments: args_json}
end)
case Message.new(%{
"role" => role,
"content" => nil,
"status" => status,
"index" => data["index"],
"tool_calls" => parsed_tool_calls
}) do
{:ok, message} -> message
{:error, changeset} -> {:error, LangChainError.exception(changeset)}
end
{:ok, _parsed} ->
# Valid JSON but not in tool_calls format — treat as single tool call
tool_call =
ToolCall.new!(%{
type: :function,
status: :complete,
name: List.first(tools).name,
arguments: content,
call_id: Ecto.UUID.generate()
})
case Message.new(%{
"role" => role,
"content" => nil,
"status" => status,
"index" => data["index"],
"tool_calls" => [tool_call]
}) do
{:ok, message} -> message
{:error, changeset} -> {:error, LangChainError.exception(changeset)}
end
{:error, _} ->
{:error,
LangChainError.exception(
type: "bad_request",
message: "response_format: expected JSON structured output but got: #{content}"
)}
end
end
def do_process_response(
_model,
%{"finish_reason" => finish_reason, "message" => %{"content" => content, "role" => role}} =
data,
tools
)
when tools == [] do
status = finish_reason_to_status(finish_reason)
case Message.new(%{
"role" => role,
"content" => content,
"status" => status,
"index" => data["index"]
}) do
{:ok, message} -> message
{:error, changeset} -> {:error, LangChainError.exception(changeset)}
end
end
def do_process_response(_model, %{"error" => %{"message" => reason, "type" => type}}) do
{:error, LangChainError.exception(type: type, message: reason)}
end
def do_process_response(_model, %{"error" => %{"message" => reason}}) do
{:error, LangChainError.exception(message: reason)}
end
def do_process_response(model, %{"choices" => %{} = usage} = _data) do
case get_token_usage(%{"usage" => usage}) do
%TokenUsage{} = token_usage ->
Callbacks.fire(model.callbacks, :on_llm_token_usage, [token_usage])
:skip
nil ->
:skip
end
end
def do_process_response(_model, %{"choices" => []}), do: :skip
def do_process_response(model, %{"choices" => choices} = data) when is_list(choices) do
# Fire token usage callback if present
if usage = Map.get(data, "usage") do
case get_token_usage(%{"usage" => usage}) do
%TokenUsage{} = token_usage ->
Callbacks.fire(model.callbacks, :on_llm_token_usage, [token_usage])
nil ->
:ok
end
end
# Process each response individually
for choice <- choices do
do_process_response(model, choice)
end
end
def do_process_response(
_model,
%{"finish_reason" => finish_reason, "message" => %{"content" => content}} = data
) do
status = finish_reason_to_status(finish_reason)
# Try to parse content as JSON for potential tool calls
case Jason.decode(content) do
{:ok, %{"tool_calls" => tool_calls}} when is_list(tool_calls) ->
# Convert JSON tool calls to Message struct with tool calls
case Message.new(%{
"role" => :assistant,
"content" => nil,
"status" => status,
"index" => data["index"],
"tool_calls" =>
Enum.map(tool_calls, fn call ->
tool_call =
ToolCall.new!(%{
type: :function,
status: :complete,
name: call["name"],
arguments: Jason.encode!(call["arguments"]),
call_id: Ecto.UUID.generate()
})
# Force the arguments field to be a JSON string even if the ToolCall schema casts it
%{tool_call | arguments: Jason.encode!(call["arguments"])}
end)
}) do
{:ok, message} -> message
{:error, changeset} -> {:error, LangChainError.exception(changeset)}
end
_ ->
# Regular message processing
case Message.new(%{
"role" => :assistant,
"content" => content,
"status" => status,
"index" => data["index"]
}) do
{:ok, message} -> message
{:error, changeset} -> {:error, LangChainError.exception(changeset)}
end
end
end
def do_process_response(_model, %{
"choices" => [
%{
"delta" => %{"role" => role, "content" => content},
"finish_reason" => finish,
"index" => index
} = _choice
]
}) do
status = finish_reason_to_status(finish)
data =
%{}
|> Map.put("role", role)
|> Map.put("content", content)
|> Map.put("index", index)
|> Map.put("status", status)
case MessageDelta.new(data) do
{:ok, message} ->
send(self(), {:message_delta, message})
message
{:error, %Ecto.Changeset{} = changeset} ->
{:error, LangChainError.exception(changeset)}
end
end
def do_process_response(
_model,
%{
"choices" => [
%{
"delta" => %{"content" => content},
"finish_reason" => finish,
"index" => index
}
| _
]
}
) do
status = finish_reason_to_status(finish)
data =
%{}
|> Map.put("role", "assistant")
|> Map.put("content", content)
|> Map.put("index", index)
|> Map.put("status", status)
case MessageDelta.new(data) do
{:ok, message} ->
send(self(), {:message_delta, message})
message
{:error, %Ecto.Changeset{} = changeset} ->
{:error, LangChainError.exception(changeset)}
end
end
def do_process_response(_model, %{"choices" => []} = _msg), do: :skip
def do_process_response(_model, {:error, %Jason.DecodeError{} = response}) do
error_message = "Received invalid JSON: #{inspect(response)}"
{:error,
LangChainError.exception(type: "invalid_json", message: error_message, original: response)}
end
def do_process_response(_model, other) do
{:error,
LangChainError.exception(
type: "unexpected_response",
message: "Unexpected response",
original: other
)}
end
defp finish_reason_to_status(nil), do: :incomplete
defp finish_reason_to_status("stop"), do: :complete
defp finish_reason_to_status("length"), do: :length
defp finish_reason_to_status(other) do
Logger.warning("Unsupported finish_reason in message. Reason: #{inspect(other)}")
nil
end
defp get_token_usage(%{"usage" => usage} = _response_body) do
TokenUsage.new!(%{
input: Map.get(usage, "prompt_tokens"),
output: Map.get(usage, "completion_tokens"),
raw: usage
})
end
defp get_token_usage(_response_body), do: nil
@doc """
Determine if an error should be retried. If `true`, a fallback LLM may be
used. If `false`, the error is understood to be more fundamental with the
request rather than a service issue and it should not be retried or fallback
to another service.
"""
@impl ChatModel
@spec retry_on_fallback?(LangChainError.t()) :: boolean()
def retry_on_fallback?(%LangChainError{type: "rate_limited"}), do: true
def retry_on_fallback?(%LangChainError{type: "rate_limit_exceeded"}), do: true
def retry_on_fallback?(%LangChainError{type: "timeout"}), do: true
def retry_on_fallback?(%LangChainError{type: "too_many_requests"}), do: true
def retry_on_fallback?(_), do: false
@doc """
Generate a config map that can later restore the model's configuration.
"""
@impl ChatModel
@spec serialize_config(t()) :: %{String.t() => any()}
def serialize_config(%ChatPerplexity{} = model) do
model
|> Utils.to_serializable_map(
[
:endpoint,
:model,
:temperature,
:top_p,
:top_k,
:max_tokens,
:stream,
:presence_penalty,
:frequency_penalty,
:search_domain_filter,
:return_images,
:return_related_questions,
:search_recency_filter,
:response_format,
:receive_timeout,
:verbose_api
],
@current_config_version
)
|> Map.delete("module")
end
@doc """
Restores the model from the config.
"""
@impl ChatModel
def restore_from_map(%{"version" => 1} = data) do
ChatPerplexity.new(data)
end
# Content delta on the final chunk (finish_reason: "stop"). Also extract
# citations from the chunk since Perplexity includes them on every chunk
# but we only need to process them once at completion. Returns a list
# [citation_delta, content_delta] so both flow through the pipeline.
defp process_stream_chunk(
_perplexity,
%{
"choices" => [
%{
"delta" => %{"content" => content},
"finish_reason" => "stop"
}
| _
]
} = chunk
) do
content_delta = %MessageDelta{content: content, role: :assistant, status: :complete}
case build_streaming_citation_delta(chunk) do
nil -> content_delta
citation_delta -> [citation_delta, content_delta]
end
end
# Content delta on intermediate chunks (no finish_reason or nil).
# Ignore citations here since they repeat on every chunk.
defp process_stream_chunk(_perplexity, %{
"choices" => [
%{"delta" => %{"content" => content}} | _
]
}) do
%MessageDelta{content: content, role: :assistant}
end
# Completion chunk with no content delta.
defp process_stream_chunk(
_perplexity,
%{
"choices" => [
%{"finish_reason" => "stop"} | _
]
} = chunk
) do
content_delta = %MessageDelta{status: :complete, role: :assistant}
case build_streaming_citation_delta(chunk) do
nil -> content_delta
citation_delta -> [citation_delta, content_delta]
end
end
defp process_stream_chunk(_perplexity, _chunk) do
%MessageDelta{}
end
# --- Citation Support ---
# Attach citations from top-level Perplexity response data to a Message.
@doc false
@spec attach_perplexity_citations(Message.t() | any(), map()) :: Message.t() | any()
def attach_perplexity_citations(%Message{} = msg, data) do
citation_urls = Map.get(data, "citations", [])
search_results = Map.get(data, "search_results", [])
sources = build_perplexity_sources(citation_urls, search_results)
if sources == [] do
msg
else
full_text = get_message_text(msg)
citations = find_citation_markers(full_text, sources)
if citations == [] do
msg
else
updated_content = attach_citations_to_first_text_part(msg.content, citations)
%{msg | content: updated_content}
end
end
end
def attach_perplexity_citations(other, _data), do: other
@doc false
@spec build_perplexity_sources(list(), list()) :: [map()]
def build_perplexity_sources(citation_urls, search_results) do
cond do
is_list(search_results) and search_results != [] ->
search_results
|> Enum.with_index()
|> Enum.map(fn {result, idx} ->
url = Map.get(result, "url") || Enum.at(citation_urls || [], idx)
metadata =
%{}
|> maybe_put_metadata("date", Map.get(result, "date"))
|> maybe_put_metadata("snippet", Map.get(result, "snippet"))
|> maybe_put_metadata("source_domain", Map.get(result, "source"))
%{
type: :web,
title: Map.get(result, "title"),
url: url,
metadata: metadata
}
end)
is_list(citation_urls) and citation_urls != [] ->
Enum.map(citation_urls, fn url ->
%{type: :web, url: url}
end)
true ->
[]
end
end
@doc false
@spec find_citation_markers(String.t(), [map()]) :: [Citation.t()]
def find_citation_markers(text, sources) when is_binary(text) and text != "" do
~r/\[(\d+)\]/
|> Regex.scan(text, return: :index)
|> Enum.flat_map(fn [{full_start, full_len}, {num_start, num_len}] ->
number_str = binary_part(text, num_start, num_len)
index = String.to_integer(number_str) - 1
case Enum.at(sources, index) do
nil ->
[]
source ->
[
Citation.new!(%{
start_index: full_start,
end_index: full_start + full_len,
source: source,
metadata: %{"provider_type" => "perplexity_citation", "citation_index" => index}
})
]
end
end)
end
def find_citation_markers(_, _), do: []
defp get_message_text(%Message{content: content}) when is_list(content) do
content
|> Enum.filter(&(&1.type == :text))
|> Enum.map_join("", & &1.content)
end
defp get_message_text(%Message{content: content}) when is_binary(content), do: content
defp get_message_text(_), do: ""
defp attach_citations_to_first_text_part(content, citations) when is_list(content) do
case Enum.find_index(content, &(&1.type == :text)) do
nil ->
content
idx ->
List.update_at(content, idx, fn part ->
%{part | citations: citations}
end)
end
end
defp attach_citations_to_first_text_part(content, _citations), do: content
# Build a citation-only MessageDelta from a streaming chunk's citation data.
# Returns nil if no citations are present.
defp build_streaming_citation_delta(chunk) do
citation_urls = Map.get(chunk, "citations", [])
search_results = Map.get(chunk, "search_results", [])
sources = build_perplexity_sources(citation_urls, search_results)
if sources == [] do
nil
else
citation_structs =
sources
|> Enum.with_index()
|> Enum.map(fn {source, idx} ->
Citation.new!(%{
source: source,
metadata: %{"provider_type" => "perplexity_citation", "citation_index" => idx}
})
end)
citation_part = %ContentPart{type: :text, content: nil, citations: citation_structs}
%MessageDelta{content: citation_part, role: :assistant, status: :incomplete}
end
end
defp maybe_put_metadata(map, _key, nil), do: map
defp maybe_put_metadata(map, key, value), do: Map.put(map, key, value)
end