Current section

Files

Jump to
langchain lib chat_models chat_req_llm.ex
Raw

lib/chat_models/chat_req_llm.ex

if Code.ensure_loaded?(ReqLLM) do
defmodule LangChain.ChatModels.ChatReqLLM do
@moduledoc """
ChatModel adapter using the `req_llm` library as the HTTP/LLM backend.
Provides access to any provider supported by req_llm (Anthropic, OpenAI, Google
Gemini, Groq, Ollama, AWS Bedrock, etc.) through the unified LangChain framework.
## Model Specification
The `model` field takes a req_llm-format specifier string: `"provider:model_id"`.
## Usage
alias LangChain.ChatModels.ChatReqLLM
alias LangChain.Chains.LLMChain
alias LangChain.Message
# Anthropic via req_llm
llm = ChatReqLLM.new!(%{model: "anthropic:claude-haiku-4-5"})
# OpenAI
llm = ChatReqLLM.new!(%{model: "openai:gpt-4o"})
# Ollama local model
llm = ChatReqLLM.new!(%{model: "ollama:llama3", base_url: "http://localhost:11434"})
# Groq with streaming
llm = ChatReqLLM.new!(%{model: "groq:llama-3.3-70b-versatile", stream: true})
{:ok, chain} =
%{llm: llm}
|> LLMChain.new!()
|> LLMChain.add_message(Message.new_user!("Hello!"))
|> LLMChain.run()
## Tool Use
Tools are translated to req_llm format automatically. The `callback` field in the
req_llm Tool struct is set to a stub — tool execution remains the LLMChain's
responsibility, as with all other ChatModel adapters.
## Provider Options
Provider-specific options (e.g. `thinking`, `tool_choice`, `seed`) can be passed
via `provider_opts`:
ChatReqLLM.new!(%{
model: "anthropic:claude-haiku-4-5",
provider_opts: %{"thinking" => %{"type" => "enabled", "budget_tokens" => 2000}}
})
## 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:
ChatReqLLM.new!(%{model: "...", retry_count: 0})
"""
use Ecto.Schema
require Logger
import Ecto.Changeset
alias __MODULE__
alias LangChain.ChatModels.ChatModel
alias LangChain.LangChainError
alias LangChain.Message
alias LangChain.Message.ContentPart
alias LangChain.Message.ToolCall
alias LangChain.Message.ToolResult
alias LangChain.MessageDelta
alias LangChain.TokenUsage
alias LangChain.Function
alias LangChain.Callbacks
alias LangChain.Utils
@behaviour ChatModel
@current_config_version 1
@primary_key false
embedded_schema do
# Required: req_llm model specifier, e.g. "anthropic:claude-haiku-4-5"
field :model, :string
# Optional: override API key (if nil, req_llm uses its layered key resolution)
field :api_key, :string, redact: true
# Optional: override base URL (useful for Ollama, Azure, VLLM, etc.)
field :base_url, :string
# Stream the response? (Phase 2: streaming support)
field :stream, :boolean, default: false
# Max tokens for the response
field :max_tokens, :integer
# Temperature (0.0–2.0 depending on provider)
field :temperature, :float
# Receive timeout in ms for non-streaming requests
field :receive_timeout, :integer, default: 60_000
# Pass-through opts forwarded verbatim to req_llm calls.
# Allows provider-specific options: thinking, tool_choice, seed, top_p, etc.
field :provider_opts, :map, default: %{}
# Callbacks for LLM events (internal, treated as private)
field :callbacks, {:array, :map}, default: []
# Log raw req_llm requests/responses for debugging
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
# Req options merged into the underlying Req.Request (advanced use)
field :req_opts, :any, virtual: true, default: []
end
@type t :: %ChatReqLLM{}
@create_fields [
:model,
:api_key,
:base_url,
:stream,
:max_tokens,
:temperature,
:receive_timeout,
:provider_opts,
:verbose_api,
:retry_count,
:req_opts
]
@required_fields [:model]
@doc """
Create a ChatReqLLM configuration.
"""
@spec new(attrs :: map()) :: {:ok, t()} | {:error, Ecto.Changeset.t()}
def new(attrs \\ %{}) do
%ChatReqLLM{}
|> cast(attrs, @create_fields)
|> common_validation()
|> apply_action(:insert)
end
@doc """
Create a ChatReqLLM configuration, raising on error if invalid.
"""
@spec new!(attrs :: map()) :: t() | no_return()
def new!(attrs \\ %{}) do
case new(attrs) do
{:ok, model} -> model
{:error, changeset} -> raise LangChainError, changeset
end
end
defp common_validation(changeset) do
changeset
|> validate_required(@required_fields)
|> validate_length(:model, min: 1)
|> validate_number(:temperature, greater_than_or_equal_to: 0, less_than_or_equal_to: 2)
|> validate_number(:max_tokens, greater_than: 0)
|> validate_number(:receive_timeout, greater_than_or_equal_to: 0)
end
# ============================================================
# ChatModel behaviour implementation
# ============================================================
@doc """
Call the LLM via req_llm with a prompt or list of messages.
"""
@impl ChatModel
def call(model, prompt, functions \\ [])
def call(%ChatReqLLM{} = model, prompt, functions) when is_binary(prompt) do
messages = [
Message.new_system!(),
Message.new_user!(prompt)
]
call(model, messages, functions)
end
def call(%ChatReqLLM{} = model, messages, functions) when is_list(messages) do
metadata = %{
model: model.model,
message_count: length(messages),
tools_count: length(functions)
}
LangChain.Telemetry.span([:langchain, :llm, :call], metadata, fn ->
try do
LangChain.Telemetry.llm_prompt(
%{system_time: System.system_time()},
%{model: model.model, messages: messages}
)
case do_api_request(model, messages, functions) do
{:error, %LangChainError{} = error} ->
{:error, error}
parsed_data ->
LangChain.Telemetry.llm_response(
%{system_time: System.system_time()},
%{model: model.model, response: parsed_data}
)
{:ok, parsed_data}
end
rescue
err in LangChainError ->
{:error, err}
end
end)
end
@doc """
Determine if an error should be retried via a fallback LLM.
"""
@impl ChatModel
@spec retry_on_fallback?(LangChainError.t()) :: boolean()
def retry_on_fallback?(%LangChainError{type: "rate_limit_exceeded"}), do: true
def retry_on_fallback?(%LangChainError{type: "rate_limited"}), do: true
def retry_on_fallback?(%LangChainError{type: "overloaded"}), do: true
def retry_on_fallback?(%LangChainError{type: "timeout"}), do: true
def retry_on_fallback?(_), do: false
@impl ChatModel
def serialize_config(%ChatReqLLM{} = model) do
Utils.to_serializable_map(
model,
[:model, :stream, :max_tokens, :temperature, :receive_timeout, :base_url, :provider_opts],
@current_config_version
)
end
@impl ChatModel
def restore_from_map(%{"version" => 1} = data) do
ChatReqLLM.new(data)
end
# ============================================================
# API Request
# ============================================================
@doc false
@spec do_api_request(t(), [Message.t()], ChatModel.tools(), non_neg_integer()) ::
Message.t() | {:error, LangChainError.t()} | no_return()
def do_api_request(model, messages, tools, retry_count \\ nil)
def do_api_request(_model, _messages, _tools, 0) do
raise LangChainError,
type: "retries_exceeded",
message: "Retries exceeded. Connection failed."
end
def do_api_request(%ChatReqLLM{stream: false} = model, messages, tools, retry_count) do
retry_count = retry_count || model.retry_count + 1
context = messages_to_req_llm_context(messages)
req_llm_tools = functions_to_req_llm_tools(tools)
opts = build_req_llm_opts(model, req_llm_tools)
if model.verbose_api do
IO.inspect(context, label: "CHAT_REQ_LLM CONTEXT")
IO.inspect(opts, label: "CHAT_REQ_LLM OPTS")
end
case ReqLLM.generate_text(model.model, context, opts) do
{:ok, %ReqLLM.Response{} = response} ->
if model.verbose_api do
IO.inspect(response, label: "CHAT_REQ_LLM RAW RESPONSE")
end
case do_process_response(model, response) do
{:error, _reason} = error ->
error
result ->
Callbacks.fire(model.callbacks, :on_llm_new_message, [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 ->
"Mint connection closed: retry count = #{inspect(retry_count)}"
end)
do_api_request(model, messages, tools, retry_count - 1)
{:error, %LangChainError{}} = error ->
error
{:error, error} ->
translate_req_llm_error(error)
other ->
Logger.warning(fn -> "Unexpected response from ReqLLM: #{inspect(other)}" end)
{:error,
LangChainError.exception(
type: "unexpected_response",
message: "Unexpected response",
original: other
)}
end
end
def do_api_request(%ChatReqLLM{stream: true} = model, messages, tools, retry_count) do
retry_count = retry_count || model.retry_count + 1
context = messages_to_req_llm_context(messages)
req_llm_tools = functions_to_req_llm_tools(tools)
opts = build_req_llm_opts(model, req_llm_tools)
if model.verbose_api do
IO.inspect(context, label: "CHAT_REQ_LLM STREAM CONTEXT")
IO.inspect(opts, label: "CHAT_REQ_LLM STREAM OPTS")
end
LangChain.Telemetry.llm_prompt(
%{system_time: System.system_time(), streaming: true},
%{model: model.model, messages: messages}
)
case ReqLLM.stream_text(model.model, context, opts) do
{:ok, stream_response} ->
# Stateful reduce: assigns a monotonic content index to each new content
# block type (thinking, text, etc.) so that MessageDelta merging places
# each type in the correct merged_content slot. Tool call argument
# fragments are emitted incrementally (mirroring ChatAnthropic's approach).
initial_state = %{next_content_index: 0, type_index_map: %{}}
{all_deltas, _final_state} =
stream_response.stream
|> Enum.reduce({[], initial_state}, fn chunk, {acc_deltas, state} ->
{new_deltas, new_state} = process_stream_chunk(chunk, state)
if new_deltas != [], do: Utils.fire_streamed_callback(model, new_deltas)
{acc_deltas ++ new_deltas, new_state}
end)
LangChain.Telemetry.emit_event(
[:langchain, :llm, :response],
%{system_time: System.system_time()},
%{model: model.model, streaming: true}
)
all_deltas
{: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 ->
"Mint connection closed: retry count = #{inspect(retry_count)}"
end)
do_api_request(model, messages, tools, retry_count - 1)
{:error, %LangChainError{}} = error ->
error
{:error, error} ->
translate_req_llm_error(error)
other ->
Logger.warning(fn -> "Unexpected response from ReqLLM stream: #{inspect(other)}" end)
{:error,
LangChainError.exception(
type: "unexpected_response",
message: "Unexpected response from stream",
original: other
)}
end
end
# Process a stream chunk with state tracking.
# Assigns a monotonic content index per chunk type so each content block type
# (thinking, text, image, etc.) gets a stable merged_content slot.
# Emits tool call start and argument fragment deltas incrementally (mirroring ChatAnthropic).
# Thinking chunk: get or assign a stable index for :thinking blocks
defp process_stream_chunk(
%ReqLLM.StreamChunk{type: :thinking, text: text},
state
)
when is_binary(text) do
{index, new_state} = get_or_assign_content_index(state, :thinking)
delta =
MessageDelta.new!(%{
role: :assistant,
content: ContentPart.new!(%{type: :thinking, content: text}),
status: :incomplete,
index: index
})
{[delta], new_state}
end
# Text content: get or assign a stable index for :content blocks
defp process_stream_chunk(
%ReqLLM.StreamChunk{type: :content, text: text},
state
)
when is_binary(text) and text != "" do
{index, new_state} = get_or_assign_content_index(state, :content)
delta =
MessageDelta.new!(%{
role: :assistant,
content: ContentPart.text!(text),
status: :incomplete,
index: index
})
{[delta], new_state}
end
# Tool call start (Anthropic streaming: metadata has start: true):
# emit initial incomplete ToolCall delta with name/id so UI can show tool in progress
defp process_stream_chunk(
%ReqLLM.StreamChunk{type: :tool_call, name: name, metadata: %{start: true} = meta},
state
)
when is_binary(name) do
id = meta[:id] || "tool_#{:erlang.unique_integer([:positive])}"
block_index = meta[:index] || 0
tool_call =
ToolCall.new!(%{
type: :function,
status: :incomplete,
call_id: id,
name: name,
index: block_index
})
delta =
MessageDelta.new!(%{
role: :assistant,
tool_calls: [tool_call],
status: :incomplete,
index: 0
})
{[delta], state}
end
# OpenAI-style streaming initial tool_call chunk: metadata carries :index
# (and usually :id), and the name is present. When :arguments is an empty
# map (placeholder for "args will arrive as tool_call_args fragments"),
# emit an :incomplete ToolCall with :arguments left nil so subsequent
# string fragments can be concatenated by ToolCall.append_arguments/2 and
# later JSON-decoded by ToolCall.complete/1. When :arguments is already a
# non-empty map (single-shot delivery — no fragments will follow), emit
# the ToolCall as :complete. In both cases the :index is preserved so the
# ToolCall lands in the same MessageDelta slot the fragment chunks target.
defp process_stream_chunk(
%ReqLLM.StreamChunk{
type: :tool_call,
name: name,
arguments: args,
metadata: %{index: block_index} = meta
},
state
)
when is_binary(name) and is_integer(block_index) do
id = meta[:id] || "tool_#{:erlang.unique_integer([:positive])}"
base_attrs = %{
type: :function,
call_id: id,
name: name,
index: block_index
}
attrs =
if is_map(args) and map_size(args) > 0 do
Map.merge(base_attrs, %{status: :complete, arguments: args})
else
Map.put(base_attrs, :status, :incomplete)
end
tool_call = ToolCall.new!(attrs)
delta =
MessageDelta.new!(%{
role: :assistant,
tool_calls: [tool_call],
status: :incomplete,
index: 0
})
{[delta], state}
end
# Tool call arg fragment: emit incomplete ToolCall delta with the partial JSON string.
# ToolCall.merge/2 will concatenate binary arguments strings across deltas.
defp process_stream_chunk(
%ReqLLM.StreamChunk{
type: :meta,
metadata: %{tool_call_args: %{index: block_index, fragment: fragment}}
},
state
)
when is_binary(fragment) and fragment != "" do
tool_call =
ToolCall.new!(%{
type: :function,
status: :incomplete,
arguments: fragment,
index: block_index
})
delta =
MessageDelta.new!(%{
role: :assistant,
tool_calls: [tool_call],
status: :incomplete,
index: 0
})
{[delta], state}
end
# All other chunks: delegate to stateless translation
defp process_stream_chunk(chunk, state) do
{translate_stream_chunk(chunk), state}
end
# Assigns a monotonic content index per chunk type. The first time a chunk type
# is seen it gets the next available index; subsequent chunks of the same type
# reuse that index so MessageDelta merging accumulates into the correct slot.
defp get_or_assign_content_index(state, chunk_type) do
case Map.get(state.type_index_map, chunk_type) do
nil ->
index = state.next_content_index
new_state = %{
state
| next_content_index: index + 1,
type_index_map: Map.put(state.type_index_map, chunk_type, index)
}
{index, new_state}
existing_index ->
{existing_index, state}
end
end
@doc """
Translate a single `ReqLLM.StreamChunk` to a list of `LangChain.MessageDelta` structs.
Returns an empty list for chunks that produce no LangChain deltas (e.g. empty content,
non-terminal metadata).
"""
@spec translate_stream_chunk(ReqLLM.StreamChunk.t()) :: [MessageDelta.t()]
def translate_stream_chunk(%ReqLLM.StreamChunk{type: :content, text: text})
when is_binary(text) and text != "" do
delta =
MessageDelta.new!(%{
role: :assistant,
content: ContentPart.text!(text),
status: :incomplete,
index: 0
})
[delta]
end
def translate_stream_chunk(%ReqLLM.StreamChunk{type: :thinking, text: text})
when is_binary(text) do
delta =
MessageDelta.new!(%{
role: :assistant,
content: ContentPart.new!(%{type: :thinking, content: text}),
status: :incomplete,
index: 0
})
[delta]
end
def translate_stream_chunk(%ReqLLM.StreamChunk{
type: :tool_call,
name: name,
arguments: args,
metadata: meta
})
when is_binary(name) do
id = (meta || %{})[:id] || "tool_#{:erlang.unique_integer([:positive])}"
args_map = if is_map(args), do: args, else: %{}
tool_call =
ToolCall.new!(%{
type: :function,
status: :complete,
call_id: id,
name: name,
arguments: args_map
})
delta =
MessageDelta.new!(%{
role: :assistant,
tool_calls: [tool_call],
status: :incomplete,
index: 0
})
[delta]
end
def translate_stream_chunk(%ReqLLM.StreamChunk{type: :meta, metadata: meta})
when is_map(meta) do
usage_deltas =
case meta[:usage] do
nil ->
[]
usage_map ->
case translate_usage(usage_map) do
nil ->
[]
token_usage ->
[MessageDelta.new!(%{role: :assistant, metadata: %{usage: token_usage}})]
end
end
finish_deltas =
if meta[:terminal?] do
status = translate_finish_reason(meta[:finish_reason])
[MessageDelta.new!(%{role: :assistant, status: status, index: 0})]
else
[]
end
usage_deltas ++ finish_deltas
end
def translate_stream_chunk(_), do: []
defp build_req_llm_opts(%ChatReqLLM{} = model, tools) do
[]
|> then(fn opts ->
if tools != [], do: Keyword.put(opts, :tools, tools), else: opts
end)
|> maybe_put(:max_tokens, model.max_tokens)
|> maybe_put(:temperature, model.temperature)
|> maybe_put(:api_key, model.api_key)
|> maybe_put(:base_url, model.base_url)
|> merge_provider_opts(model.provider_opts)
end
defp maybe_put(opts, _key, nil), do: opts
defp maybe_put(opts, key, value), do: Keyword.put(opts, key, value)
defp merge_provider_opts(opts, nil), do: opts
defp merge_provider_opts(opts, provider_opts) when provider_opts == %{}, do: opts
defp merge_provider_opts(opts, provider_opts) when is_map(provider_opts) do
extra =
Enum.map(provider_opts, fn {k, v} ->
key = if is_binary(k), do: String.to_existing_atom(k), else: k
{key, v}
end)
Keyword.merge(opts, extra)
end
defp translate_req_llm_error(error) do
case error do
%{status: 401} ->
{:error,
LangChainError.exception(
type: "authentication_error",
message: "Authentication failed",
original: error
)}
%{status: 429} ->
{:error,
LangChainError.exception(
type: "rate_limit_exceeded",
message: "Rate limit exceeded",
original: error
)}
%{status: 529} ->
{:error,
LangChainError.exception(
type: "overloaded",
message: "Service overloaded",
original: error
)}
%{status: status} when is_integer(status) and status >= 500 ->
{:error,
LangChainError.exception(
type: "server_error",
message: "Server error",
original: error
)}
_ ->
Logger.warning(fn -> "Unhandled error from ReqLLM: #{inspect(error)}" end)
{:error,
LangChainError.exception(
type: "unhandled_error",
message: "Unhandled error from ReqLLM",
original: error
)}
end
end
# ============================================================
# Outbound Translation: LangChain → ReqLLM
# ============================================================
@doc """
Convert a list of LangChain messages to a `ReqLLM.Context`.
Tool messages are expanded: a single LangChain `:tool` message (which may carry
multiple `ToolResult` structs) becomes one `ReqLLM.Message` per result, matching
the one-result-per-message convention expected by OpenAI-compatible providers.
"""
@spec messages_to_req_llm_context([Message.t()]) :: ReqLLM.Context.t()
def messages_to_req_llm_context(messages) do
req_llm_messages =
messages
|> Enum.flat_map(&message_to_req_llm_messages/1)
ReqLLM.Context.new(req_llm_messages)
end
@doc """
Convert a single LangChain `Message` to a list of `ReqLLM.Message` structs.
Most roles map 1-to-1. The `:tool` role expands to one message per `ToolResult`.
"""
@spec message_to_req_llm_messages(Message.t()) :: [ReqLLM.Message.t()]
def message_to_req_llm_messages(%Message{role: :tool, tool_results: results})
when is_list(results) do
Enum.map(results, fn %ToolResult{} = result ->
content = tool_result_content_to_req_llm(result.content)
%ReqLLM.Message{
role: :tool,
tool_call_id: result.tool_call_id,
content: content
}
end)
end
def message_to_req_llm_messages(%Message{role: :assistant, tool_calls: calls} = msg)
when is_list(calls) and calls != [] do
content = lc_content_to_req_llm(msg.content)
req_tool_calls = Enum.map(calls, &lc_tool_call_to_req_llm/1)
[%ReqLLM.Message{role: :assistant, content: content, tool_calls: req_tool_calls}]
end
def message_to_req_llm_messages(%Message{} = msg) do
content = lc_content_to_req_llm(msg.content)
[%ReqLLM.Message{role: msg.role, content: content}]
end
defp lc_content_to_req_llm(nil), do: []
defp lc_content_to_req_llm(content) when is_binary(content) do
[ReqLLM.Message.ContentPart.text(content)]
end
defp lc_content_to_req_llm(parts) when is_list(parts) do
parts
|> Enum.map(&content_part_to_req_llm/1)
|> Enum.reject(&is_nil/1)
end
@doc """
Convert a LangChain `ContentPart` to a `ReqLLM.Message.ContentPart`.
Returns `nil` for unsupported types (they are filtered out of the content list).
"""
@spec content_part_to_req_llm(ContentPart.t()) :: ReqLLM.Message.ContentPart.t() | nil
def content_part_to_req_llm(%ContentPart{type: :text, content: text}) do
ReqLLM.Message.ContentPart.text(text || "")
end
def content_part_to_req_llm(%ContentPart{type: :thinking, content: text}) do
ReqLLM.Message.ContentPart.thinking(text || "")
end
def content_part_to_req_llm(%ContentPart{type: :image_url, content: url}) do
ReqLLM.Message.ContentPart.image_url(url)
end
def content_part_to_req_llm(%ContentPart{type: :image, content: b64, options: opts}) do
media_type = opts |> Keyword.get(:media, :png) |> media_to_mime()
decoded = Base.decode64!(b64)
ReqLLM.Message.ContentPart.image(decoded, media_type)
end
def content_part_to_req_llm(%ContentPart{type: :file, content: b64, options: opts}) do
media_type =
(opts || []) |> Keyword.get(:media, "application/octet-stream") |> media_to_mime()
filename = (opts || []) |> Keyword.get(:filename, "file")
decoded = Base.decode64!(b64)
ReqLLM.Message.ContentPart.file(decoded, filename, media_type)
end
def content_part_to_req_llm(%ContentPart{type: :file_url, content: url}) do
Logger.warning(
"ContentPart type :file_url is not directly supported by ReqLLM; converting to text URL reference"
)
ReqLLM.Message.ContentPart.text("URL: #{url}")
end
def content_part_to_req_llm(%ContentPart{type: :unsupported}) do
Logger.warning("Unsupported ContentPart type skipped during ChatReqLLM translation")
nil
end
defp tool_result_content_to_req_llm(nil) do
[ReqLLM.Message.ContentPart.text("")]
end
defp tool_result_content_to_req_llm(content) when is_binary(content) do
[ReqLLM.Message.ContentPart.text(content)]
end
defp tool_result_content_to_req_llm(parts) when is_list(parts) do
parts
|> Enum.map(&content_part_to_req_llm/1)
|> Enum.reject(&is_nil/1)
end
defp lc_tool_call_to_req_llm(%ToolCall{call_id: id, name: name, arguments: args}) do
args_json = Jason.encode!(args || %{})
ReqLLM.ToolCall.new(id, name, args_json)
end
@doc """
Convert a list of LangChain `Function` structs to `ReqLLM.Tool` structs.
Each tool gets a stub callback — tool execution remains the LLMChain's responsibility.
"""
@spec functions_to_req_llm_tools([Function.t()] | nil) :: [ReqLLM.Tool.t()]
def functions_to_req_llm_tools(nil), do: []
def functions_to_req_llm_tools([]), do: []
def functions_to_req_llm_tools(functions) when is_list(functions) do
Enum.map(functions, &function_to_req_llm_tool/1)
end
@doc """
Convert a single `LangChain.Function` to a `ReqLLM.Tool` with a stub callback.
The stub callback is never invoked in normal LangChain operation — the tool
definition is only used for schema generation (telling the LLM what tools exist).
"""
@spec function_to_req_llm_tool(Function.t()) :: ReqLLM.Tool.t()
def function_to_req_llm_tool(%Function{} = fun) do
schema = fun.parameters_schema || %{}
ReqLLM.Tool.new!(
name: fun.name,
description: fun.description || "",
parameter_schema: schema,
callback: fn _ -> {:ok, "stub"} end
)
end
defp media_to_mime(:png), do: "image/png"
defp media_to_mime(:jpg), do: "image/jpeg"
defp media_to_mime(:jpeg), do: "image/jpeg"
defp media_to_mime(:gif), do: "image/gif"
defp media_to_mime(:webp), do: "image/webp"
defp media_to_mime(:pdf), do: "application/pdf"
defp media_to_mime(:text), do: "text/plain"
defp media_to_mime(s) when is_binary(s), do: s
defp media_to_mime(_), do: "application/octet-stream"
# ============================================================
# Inbound Translation: ReqLLM → LangChain
# ============================================================
@doc """
Convert a `ReqLLM.Response` to a `LangChain.Message`.
"""
@spec do_process_response(t(), ReqLLM.Response.t()) ::
Message.t() | {:error, LangChainError.t()}
def do_process_response(%ChatReqLLM{} = _model, %ReqLLM.Response{error: error})
when not is_nil(error) do
{:error,
LangChainError.exception(
type: "api_error",
message: "API returned an error",
original: error
)}
end
def do_process_response(%ChatReqLLM{} = _model, %ReqLLM.Response{message: nil}) do
{:error,
LangChainError.exception(
type: "unexpected_response",
message: "Response contained no message"
)}
end
def do_process_response(%ChatReqLLM{} = _model, %ReqLLM.Response{} = response) do
content_parts = translate_response_content(response.message.content)
tool_calls = translate_response_tool_calls(response.message.tool_calls)
status = translate_finish_reason(response.finish_reason)
usage = translate_usage(response.usage)
%{
role: :assistant,
content: content_parts,
tool_calls: tool_calls,
status: status
}
|> Message.new()
|> TokenUsage.set_wrapped(usage)
|> unwrap_message()
end
defp unwrap_message({:ok, message}), do: message
defp unwrap_message({:error, %Ecto.Changeset{} = changeset}) do
{:error, LangChainError.exception(changeset)}
end
defp translate_response_content(nil), do: []
defp translate_response_content([]), do: []
defp translate_response_content(parts) when is_list(parts) do
parts
|> Enum.map(&req_llm_content_part_to_lc/1)
|> Enum.reject(&is_nil/1)
end
defp req_llm_content_part_to_lc(%ReqLLM.Message.ContentPart{type: :text, text: text}) do
ContentPart.text!(text || "")
end
defp req_llm_content_part_to_lc(%ReqLLM.Message.ContentPart{
type: :thinking,
text: text,
metadata: meta
}) do
signature = (meta || %{})[:signature]
opts = if signature, do: [signature: signature], else: []
ContentPart.new!(%{type: :thinking, content: text, options: opts})
end
defp req_llm_content_part_to_lc(%ReqLLM.Message.ContentPart{type: :image_url, url: url}) do
ContentPart.new!(%{type: :image_url, content: url})
end
defp req_llm_content_part_to_lc(%ReqLLM.Message.ContentPart{
type: :image,
data: data,
media_type: media_type
}) do
ContentPart.new!(%{
type: :image,
content: Base.encode64(data),
options: [media: media_type]
})
end
defp req_llm_content_part_to_lc(%ReqLLM.Message.ContentPart{
type: :file,
data: data,
media_type: media_type,
filename: filename
}) do
ContentPart.new!(%{
type: :file,
content: Base.encode64(data),
options: [media: media_type, filename: filename]
})
end
defp req_llm_content_part_to_lc(other) do
Logger.warning("Unknown ReqLLM ContentPart type skipped: #{inspect(other)}")
nil
end
defp translate_response_tool_calls(nil), do: nil
defp translate_response_tool_calls([]), do: nil
defp translate_response_tool_calls(tool_calls) when is_list(tool_calls) do
Enum.map(tool_calls, fn %ReqLLM.ToolCall{
id: id,
function: %{name: name, arguments: args_json}
} ->
arguments =
case Jason.decode(args_json || "{}") do
{:ok, map} when is_map(map) -> map
_ -> %{}
end
ToolCall.new!(%{
type: :function,
status: :complete,
call_id: id,
name: name,
arguments: arguments
})
end)
end
@doc """
Translate a `req_llm` `finish_reason` atom to a `LangChain.Message` status atom.
"""
@spec translate_finish_reason(atom() | nil) :: atom()
def translate_finish_reason(:stop), do: :complete
def translate_finish_reason(:tool_calls), do: :complete
def translate_finish_reason(:length), do: :length
def translate_finish_reason(:content_filter), do: :complete
def translate_finish_reason(:cancelled), do: :complete
def translate_finish_reason(:incomplete), do: :length
def translate_finish_reason(:error), do: :complete
def translate_finish_reason(:unknown), do: :complete
def translate_finish_reason(nil), do: :complete
def translate_finish_reason(other) do
Logger.warning("Unknown finish_reason from ReqLLM: #{inspect(other)}")
:complete
end
@doc """
Translate a `req_llm` usage map to a `LangChain.TokenUsage` struct.
"""
@spec translate_usage(map() | nil) :: TokenUsage.t() | nil
def translate_usage(nil), do: nil
def translate_usage(usage) when is_map(usage) do
input = usage[:input_tokens] || 0
output = usage[:output_tokens] || 0
case TokenUsage.new(%{input: input, output: output, raw: usage}) do
{:ok, token_usage} -> token_usage
_ -> nil
end
end
end
end