Current section
Files
Jump to
Current section
Files
lib/planck/ai/adapter.ex
defmodule Planck.AI.Adapter do
@moduledoc """
Translates between Planck's types and `req_llm`'s call interface.
This is the only module that knows `req_llm`'s input and output shapes.
Everything above this layer works exclusively with `Planck.AI` structs.
"""
alias Planck.AI.{Context, Message, Model, Tool}
alias ReqLLM.Message.ContentPart
@doc """
Converts a `Planck.AI.Model`, `Planck.AI.Context`, and call-site opts into
the three arguments expected by `req_llm`'s `stream_text/3`:
`{model_spec_string, req_llm_context, opts}`.
Tools from the context are added to opts as `%ReqLLM.Tool{}` structs.
Inference params (e.g. `temperature:`, `max_tokens:`) in opts are forwarded
directly to `req_llm`, which handles per-provider translation.
## Examples
iex> model = %Planck.AI.Model{id: "claude-sonnet-4-6", provider: :anthropic, context_window: 200_000, max_tokens: 8_096}
iex> context = %Planck.AI.Context{messages: []}
iex> {model_spec, _ctx, _opts} = Planck.AI.Adapter.to_req_llm(model, context, [])
iex> model_spec
"anthropic:claude-sonnet-4-6"
"""
@spec to_req_llm(Model.t(), Context.t(), keyword()) ::
{model_spec, req_llm_context, opts}
when model_spec: String.t() | map(),
req_llm_context: ReqLLM.Context.t(),
opts: keyword()
def to_req_llm(model, context, opts)
def to_req_llm(%Model{} = model, %Context{} = context, opts) do
model_spec = build_model_spec(model)
req_context = build_context(context)
req_opts =
opts
|> add_base_url(model)
|> add_tools(context.tools)
{model_spec, req_context, req_opts}
end
# --- Private ---
@spec build_model_spec(Model.t()) :: String.t() | map()
defp build_model_spec(model)
defp build_model_spec(%Model{provider: :anthropic} = m) do
"anthropic:#{m.model || m.id}"
end
defp build_model_spec(%Model{provider: :google} = m) do
"google:#{m.model || m.id}"
end
defp build_model_spec(%Model{provider: :openai, base_url: nil} = m) do
"openai:#{m.model || m.id}"
end
defp build_model_spec(%Model{provider: :openai} = m) do
%{provider: :openai, id: m.model || m.id}
end
@spec build_context(Context.t()) :: ReqLLM.Context.t()
defp build_context(context)
defp build_context(%Context{system: system, messages: messages}) do
parts = Enum.flat_map(messages, &message_to_req_llm/1)
parts = if system, do: [ReqLLM.Context.system(system) | parts], else: parts
ReqLLM.Context.new(parts)
end
@spec add_base_url(keyword(), Model.t()) :: keyword()
defp add_base_url(opts, model)
defp add_base_url(opts, %Model{base_url: nil}) do
opts
end
defp add_base_url(opts, %Model{provider: :openai, base_url: url, has_api_key: false}) do
opts
|> Keyword.put_new(:base_url, url)
|> Keyword.put_new(:api_key, "not-needed")
end
defp add_base_url(opts, %Model{provider: :openai, base_url: url, identifier: id}) do
effective_id = id || "OPENAI"
resolved = resolve_api_key(effective_id) || "not-needed"
opts
|> Keyword.put_new(:base_url, url)
|> Keyword.put_new(:api_key, resolved)
end
defp add_base_url(opts, %Model{base_url: url}) do
Keyword.put_new(opts, :base_url, url)
end
@spec resolve_api_key(String.t()) :: String.t() | nil
defp resolve_api_key(id)
defp resolve_api_key(id) do
System.get_env("#{id}_API_KEY")
end
@spec add_tools(keyword(), [Tool.t()]) :: keyword()
defp add_tools(opts, tools)
defp add_tools(opts, []) do
opts
end
defp add_tools(opts, tools) do
tools
|> Enum.flat_map(fn tool ->
case build_req_llm_tool(tool) do
{:ok, t} -> [t]
_error -> []
end
end)
|> case do
[] ->
opts
[_ | _] = req_tools ->
Keyword.put(opts, :tools, req_tools)
end
end
@spec build_req_llm_tool(Tool.t()) :: {:ok, ReqLLM.Tool.t()} | {:error, term()}
defp build_req_llm_tool(tool)
defp build_req_llm_tool(%Tool{name: name, description: desc, parameters: params}) do
ReqLLM.Tool.new(
name: name,
description: desc,
parameter_schema: params || %{},
callback: fn _args -> {:ok, nil} end
)
end
@spec message_to_req_llm(Message.t()) :: [term()]
defp message_to_req_llm(message)
defp message_to_req_llm(%Message{role: :user, content: parts}) do
[ReqLLM.Context.user(Enum.map(parts, &content_part_to_req_llm/1))]
end
defp message_to_req_llm(%Message{role: :assistant, content: parts}) do
tool_calls =
for {:tool_call, id, name, args} <- parts do
ReqLLM.ToolCall.new(id, name, Jason.encode!(args))
end
case tool_calls do
[] ->
[ReqLLM.Context.assistant(Enum.map(parts, &content_part_to_req_llm/1))]
_ ->
text =
Enum.find_value(parts, fn
{:text, t} -> t
_ -> nil
end)
[ReqLLM.Context.assistant(text || "", tool_calls: tool_calls)]
end
end
defp message_to_req_llm(%Message{role: :tool_result, content: parts}) do
for {:tool_result, id, result} <- parts do
content = if is_binary(result), do: result, else: Jason.encode!(result)
ReqLLM.Context.tool_result(id, content)
end
end
@spec content_part_to_req_llm(Message.content_part()) :: term()
defp content_part_to_req_llm(part)
defp content_part_to_req_llm({:text, text}) do
ContentPart.text(text)
end
defp content_part_to_req_llm({:image, data, mime_type}) do
ContentPart.image(data, mime_type)
end
defp content_part_to_req_llm({:image_url, url}) do
ContentPart.image_url(url)
end
defp content_part_to_req_llm({:file, data, mime_type}) do
ContentPart.file(data, "", mime_type)
end
defp content_part_to_req_llm({:video_url, url}) do
ContentPart.video_url(url)
end
defp content_part_to_req_llm({:thinking, text}) do
ContentPart.thinking(text)
end
defp content_part_to_req_llm({:tool_call, _id, _name, _args}) do
ContentPart.text("")
end
defp content_part_to_req_llm({:tool_result, _id, _result}) do
ContentPart.text("")
end
end