Current section

Files

Jump to
openai_responses lib responses prompt.ex
Raw

lib/responses/prompt.ex

defmodule OpenAI.Responses.Prompt do
@moduledoc """
Helpers for composing and normalizing the `input` option for Responses.
These helpers are pure and return a new options map. They do not enforce
any domain policy or deduplicate messages; they only help safely compose
inputs and execute function outputs.
All helpers normalize `:input` to a list of message maps. When strings are
provided (either as the whole input or individual items), they are converted
to `%{role: :user, content: string}` messages. The `:input` option is never
set to a single map — even a single message is wrapped in a list.
"""
alias OpenAI.Responses.Options
@type opts :: map() | keyword()
@doc """
Append message(s) to `opts.input`.
Accepts a binary, a `%{role, content}` map, or a list of such values.
Returns a new options map with `:input` normalized to a list of message maps.
"""
@spec append(opts, binary | map() | [binary | map()]) :: map()
def append(opts, messages) do
opts = normalize_opts(opts)
existing_raw = Map.get(opts, :input) || Map.get(opts, "input")
new_msgs = normalize_messages(messages)
case existing_raw do
nil ->
# Start a new list even for a single message
Map.put(opts, :input, new_msgs)
existing when not is_list(existing) ->
existing_list = normalize_input(existing)
Map.put(opts, :input, existing_list ++ new_msgs)
existing when is_list(existing) ->
existing_list = normalize_input(existing)
Map.put(opts, :input, existing_list ++ new_msgs)
end
end
@doc """
Prepend message(s) to `opts.input`.
Same accepted inputs and return shape as `append/2`.
"""
@spec prepend(opts, binary | map() | [binary | map()]) :: map()
def prepend(opts, messages) do
opts = normalize_opts(opts)
existing_raw = Map.get(opts, :input) || Map.get(opts, "input")
new_msgs = normalize_messages(messages)
case existing_raw do
nil ->
# Start a new list even for a single message
Map.put(opts, :input, new_msgs)
existing when not is_list(existing) ->
existing_list = normalize_input(existing)
Map.put(opts, :input, new_msgs ++ existing_list)
existing when is_list(existing) ->
existing_list = normalize_input(existing)
Map.put(opts, :input, new_msgs ++ existing_list)
end
end
@doc """
Shorthand to append a user message.
"""
@spec add_user(opts, binary) :: map()
def add_user(opts, content) when is_binary(content) do
append(opts, %{role: :user, content: content})
end
@doc """
Shorthand to append a developer message.
"""
@spec add_developer(opts, binary) :: map()
def add_developer(opts, content) when is_binary(content) do
append(opts, %{role: :developer, content: content})
end
@doc """
Shorthand to append a system message.
"""
@spec add_system(opts, binary) :: map()
def add_system(opts, content) when is_binary(content) do
append(opts, %{role: :system, content: content})
end
@doc """
Execute `function_calls` using provided `functions` and append the outputs.
The produced messages follow the OpenAI Responses format:
`%{type: "function_call_output", call_id: id, output: result}`
"""
@spec add_function_outputs(opts, [map()], map() | keyword()) :: map()
def add_function_outputs(opts, function_calls, functions)
when is_list(function_calls) and (is_map(functions) or is_list(functions)) do
outputs = execute_function_calls(function_calls, functions)
append(opts, outputs)
end
# -- Internals -----------------------------------------------------------------
# Normalize opts to a map (atom keys preserved as-is). Always return a map.
defp normalize_opts(opts) when is_map(opts), do: opts
defp normalize_opts(opts) when is_list(opts), do: Map.new(opts)
# Normalize an existing input field into a list of message maps.
defp normalize_input(nil), do: []
defp normalize_input(input) when is_binary(input), do: [%{role: :user, content: input}]
defp normalize_input(%{role: _r, content: _c} = msg), do: [msg]
defp normalize_input(%{type: _t} = msg), do: [msg]
defp normalize_input(list) when is_list(list), do: Enum.map(list, &to_message/1)
defp normalize_input(other),
do: raise(ArgumentError, "Invalid input value for :input: #{inspect(other)}")
# Normalize provided messages (single or list) into message maps
defp normalize_messages(messages) when is_binary(messages),
do: [%{role: :user, content: messages}]
defp normalize_messages(%{role: _r, content: _c} = msg), do: [msg]
defp normalize_messages(%{"role" => _r, "content" => _c} = msg), do: [msg]
defp normalize_messages(%{type: _t} = msg), do: [msg]
defp normalize_messages(%{"type" => _t} = msg), do: [msg]
defp normalize_messages(list) when is_list(list), do: Enum.map(list, &to_message/1)
defp normalize_messages(other),
do: raise(ArgumentError, "Invalid messages value: #{inspect(other)}")
defp to_message(s) when is_binary(s), do: %{role: :user, content: s}
defp to_message(%{role: _r, content: _c} = msg), do: msg
# Allow tool/function output or other typed input items to pass through
defp to_message(%{type: _t} = msg), do: msg
defp to_message(%{"role" => _r, "content" => _c} = msg) do
# Keep string-keyed maps as-is; Options.normalize will stringify later anyway
msg
end
defp to_message(%{"type" => _t} = msg), do: msg
defp to_message(other),
do: raise(ArgumentError, "Expected binary or %{role, content} map, got: #{inspect(other)}")
# Execute function calls and return function_call_output messages
defp execute_function_calls(function_calls, functions) do
Enum.map(function_calls, fn call ->
call = Options.stringify_keys_shallow(call)
function_name = call["name"]
function = get_function(functions, function_name)
result =
case function do
nil ->
"Error: Function '#{function_name}' not found"
f when is_function(f, 1) ->
try do
f.(call["arguments"])
rescue
e -> "Error calling function '#{function_name}': #{Exception.message(e)}"
end
_ ->
"Error: Invalid function for '#{function_name}'"
end
%{
type: "function_call_output",
call_id: call["call_id"],
output: result
}
end)
end
# Allow functions to be provided as a map or keyword list
defp get_function(functions, name) when is_map(functions) do
Map.get(functions, name) || Map.get(functions, to_string(name))
end
defp get_function(functions, name) when is_list(functions) do
name_atom =
cond do
is_atom(name) ->
name
is_binary(name) ->
try do
String.to_existing_atom(name)
rescue
ArgumentError -> nil
end
true ->
nil
end
Keyword.get(functions, name_atom)
end
end