Packages

Lightweight Elixir client for LLM APIs

Current section

Files

Jump to
llm lib llm adapter openai_response.ex
Raw

lib/llm/adapter/openai_response.ex

defmodule LLM.Adapter.OpenAIResponse do
@moduledoc """
OpenAI Responses API adapter.
Handles the wire format for the `/v1/responses` endpoint. This is OpenAI's
newer API that supports multi-turn conversations via `previous_response_id`
and structured output items.
## Capabilities
- Streaming via SSE with typed output items
- Tool/function calling with `function_call` and `function_call_output` items
- Reasoning/thinking via `reasoning` output items
- Response chaining via `previous_response_id`
- Model listing via `/v1/models`
## Request format
Input uses the Responses API's item format:
- Message: `%{"type" => "message", "role" => ..., "content" => [...]}`
- Function call output: `%{"type" => "function_call_output", "call_id" => ..., "output" => ...}`
System instructions are sent as an `"instructions"` field.
## SSE events
- `event: response.created` — response started
- `event: response.output_text.delta` — text chunk
- `event: response.reasoning.delta` — thinking/reasoning chunk
- `event: response.function_call_arguments.delta` — tool call arguments
- `event: response.output_item.done` — tool call complete
- `event: response.completed` — end of stream
"""
@behaviour LLM.Adapter
@impl true
def init_stream_state, do: %{function_calls: %{}, response_id: nil}
@impl true
def build_request(context, opts) do
%{
"model" => Keyword.fetch!(opts, :model),
"input" => encode_input(context.messages),
"stream" => true
}
|> maybe_put("instructions", context.system)
|> maybe_put("max_output_tokens", opts[:max_tokens])
|> maybe_put("temperature", opts[:temperature])
|> maybe_put("reasoning", normalize_thinking(opts[:thinking]))
|> maybe_put("tools", encode_tools(context.tools))
|> maybe_put("previous_response_id", get_in(context.provider_state, ["previous_response_id"]))
|> maybe_put("text", encode_structured_output(opts[:structured_output]))
end
@impl true
def decode_response(%{"output" => output} = raw) do
usage =
case raw do
%{"usage" => u} ->
LLM.Usage.new(
input_tokens: u["input_tokens"],
output_tokens: u["output_tokens"],
total_tokens: u["total_tokens"]
)
_ ->
nil
end
{:ok,
%LLM.Response{
message: build_response_message(output),
usage: usage,
provider_state: provider_state_from_response(raw),
raw: raw
}}
end
def decode_response(other), do: {:error, {:invalid_response, other}}
@impl true
def stream_path, do: "/v1/responses"
@impl true
def stream_headers(_opts), do: [{"content-type", "application/json"}]
@impl true
def auth_headers(%{api_key: api_key}, _opts), do: [{"authorization", "Bearer #{api_key}"}]
@impl true
def decode_chunk("event: response.completed\n" <> data, state) do
case decode_event(data) do
{:ok, %{"response" => %{"id" => response_id} = response}} ->
usage = extract_streaming_usage(response)
next_state = %{state | response_id: response_id}
chunks = if usage, do: [%LLM.Stream.Stop{reason: :stop, usage: usage}], else: []
{chunks, next_state}
_ ->
{:done, state}
end
end
def decode_chunk("event: response.created\n" <> data, state) do
case decode_event(data) do
{:ok, %{"response" => %{"id" => response_id}}} -> {[], %{state | response_id: response_id}}
_ -> {[], state}
end
end
def decode_chunk("event: response.output_item.added\n" <> data, state) do
case decode_event(data) do
{:ok, %{"item" => %{"type" => "function_call"} = item}} ->
index = item["output_index"] || item["index"] || map_size(state.function_calls)
call = %{id: item["call_id"], name: item["name"], arguments_json: item["arguments"] || ""}
{[], put_in(state, [:function_calls, index], call)}
_ ->
{[], state}
end
end
def decode_chunk("event: response.output_text.delta\n" <> data, state) do
case decode_event(data) do
{:ok, %{"delta" => text}} when is_binary(text) -> {[%LLM.Stream.Chunk{text: text}], state}
_ -> {[], state}
end
end
def decode_chunk("event: response.reasoning_summary_text.delta\n" <> data, state) do
case decode_event(data) do
{:ok, %{"delta" => text}} when is_binary(text) ->
{[%LLM.Stream.Thinking{text: text}], state}
_ ->
{[], state}
end
end
def decode_chunk("event: response.reasoning.delta\n" <> data, state) do
case decode_event(data) do
{:ok, %{"delta" => text}} when is_binary(text) ->
{[%LLM.Stream.Thinking{text: text}], state}
_ ->
{[], state}
end
end
def decode_chunk("event: response.function_call_arguments.delta\n" <> data, state) do
case decode_event(data) do
{:ok, %{"call_id" => id, "delta" => args_json} = event} ->
index = event["output_index"] || find_call_index(state.function_calls, id)
call =
Map.get(state.function_calls, index, %{
id: id,
name: event["name"] || "",
arguments_json: ""
})
next_state =
put_in(state, [:function_calls, index], %{
call
| arguments_json: call.arguments_json <> args_json
})
{[], next_state}
_ ->
{[], state}
end
end
def decode_chunk("event: response.output_item.done\n" <> data, state) do
case decode_event(data) do
{:ok, %{"item" => %{"type" => "function_call"} = item}} ->
index =
item["output_index"] || item["index"] ||
find_call_index(state.function_calls, item["call_id"])
call =
Map.get(state.function_calls, index, %{
id: item["call_id"],
name: item["name"],
arguments_json: item["arguments"] || ""
})
arguments_json = if item["arguments"], do: item["arguments"], else: call.arguments_json
arguments =
case Jason.decode(arguments_json || "") do
{:ok, parsed} -> parsed
_ -> %{}
end
chunk = %LLM.Stream.ToolCall{
id: call.id || item["call_id"],
name: call.name || item["name"],
arguments: arguments,
index: index,
complete: true
}
{[chunk], state}
_ ->
{[], state}
end
end
def decode_chunk(_, state), do: {[], state}
@impl true
def normalize_thinking(level) when level in [:low, :medium, :high, :xhigh, :max] do
%{"effort" => encode_reasoning_effort(level), "summary" => "auto"}
end
def normalize_thinking(false), do: nil
def normalize_thinking(nil), do: nil
def normalize_thinking(val), do: val
defp encode_input(messages) do
Enum.flat_map(messages, fn
%LLM.Message{role: :tool, tool_call_id: tool_call_id, content: content} ->
[
%{
"type" => "function_call_output",
"call_id" => tool_call_id,
"output" => to_string(content)
}
]
%LLM.Message{role: :assistant} = msg ->
thinking_items =
case msg.thinking do
nil -> []
t -> [%{"type" => "reasoning", "summary" => [%{"text" => thinking_text(t)}]}]
end
text_items =
if msg.content && msg.content != "",
do: [%{"type" => "output_text", "text" => msg.content}],
else: []
message_content = thinking_items ++ text_items
tool_call_items =
(msg.tools || [])
|> Enum.map(fn %{id: id, name: name, args: args} ->
%{
"type" => "function_call",
"call_id" => id,
"name" => name,
"arguments" => Jason.encode!(args)
}
end)
[
%{"type" => "message", "role" => "assistant", "content" => message_content}
| tool_call_items
]
%LLM.Message{role: role, content: content} when is_binary(content) ->
[
%{
"type" => "message",
"role" => to_string(role),
"content" => [%{"type" => "input_text", "text" => content}]
}
]
%LLM.Message{role: :user, content: parts} when is_list(parts) ->
[%{"type" => "message", "role" => "user", "content" => encode_user_content(parts)}]
end)
end
defp encode_user_content(parts) do
Enum.flat_map(parts, fn
{:text, text} ->
[%{"type" => "input_text", "text" => text}]
{:tool_result, id, _name, content} ->
[%{"type" => "function_call_output", "call_id" => id, "output" => to_string(content)}]
_ ->
[]
end)
end
defp encode_tools([]), do: nil
defp encode_tools(tools) do
Enum.map(tools, fn
%LLM.Tool{} = tool ->
%{
"type" => "function",
"name" => tool.name,
"description" => tool.description,
"parameters" => tool.input_schema
}
module when is_atom(module) ->
module |> LLM.Tool.from_module() |> then(&List.first(encode_tools([&1])))
end)
end
defp encode_structured_output(nil), do: nil
defp encode_structured_output(spec) do
{name, schema} = extract_schema(spec)
%{"format" => %{"type" => "json_schema", "name" => name, "schema" => schema}}
end
defp extract_schema(%{name: name, schema: schema}), do: {to_string(name), schema}
defp extract_schema(%{"name" => name, "schema" => schema}), do: {name, schema}
defp extract_schema(schema), do: {"output", schema}
defp encode_reasoning_effort(:low), do: "low"
defp encode_reasoning_effort(:medium), do: "medium"
defp encode_reasoning_effort(:high), do: "high"
defp encode_reasoning_effort(:xhigh), do: "high"
defp encode_reasoning_effort(:max), do: "high"
defp decode_event(data) do
data
|> String.replace_prefix("data: ", "")
|> Jason.decode()
end
defp build_response_message(output) do
parts =
Enum.flat_map(output, fn
%{"type" => "message", "content" => content} ->
Enum.flat_map(content, fn
%{"type" => "output_text", "text" => text} when is_binary(text) and text != "" ->
[{:text, text}]
_ ->
[]
end)
%{"type" => "reasoning", "summary" => summary} when is_list(summary) ->
Enum.flat_map(summary, fn
%{"text" => text} when is_binary(text) and text != "" -> [{:thinking, text}]
_ -> []
end)
%{"type" => "function_call", "call_id" => id, "name" => name, "arguments" => args} ->
[{:tool_use, id, name, decode_arguments(args)}]
_ ->
[]
end)
thinking =
Enum.find_value(parts, fn
{:thinking, v} -> {:found, v}
_ -> nil
end)
|> then(fn
nil -> nil
{:found, v} -> v
end)
text =
parts
|> Enum.flat_map(fn
{:text, t} -> [t]
_ -> []
end)
|> IO.iodata_to_binary()
tools =
parts
|> Enum.flat_map(fn
{:tool_use, id, name, args} -> [%{id: id, name: name, args: args}]
_ -> []
end)
%LLM.Message{
role: :assistant,
content: if(text == "", do: nil, else: text),
thinking: thinking,
tools: if(tools == [], do: nil, else: tools)
}
end
defp provider_state_from_response(%{"id" => response_id}) when is_binary(response_id),
do: %{"previous_response_id" => response_id}
defp provider_state_from_response(_), do: %{}
defp thinking_text(%{text: text}), do: text
defp thinking_text(text), do: text
defp decode_arguments(nil), do: %{}
defp decode_arguments(""), do: %{}
defp decode_arguments(arguments), do: Jason.decode!(arguments)
defp find_call_index(function_calls, call_id) do
Enum.find_value(function_calls, 0, fn {index, call} -> if call.id == call_id, do: index end)
end
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, _key, []), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
defp extract_streaming_usage(%{"usage" => u}) when is_map(u) do
cache_read = get_in(u, ["input_tokens_details", "cached_tokens"]) || 0
_reasoning = get_in(u, ["output_tokens_details", "reasoning_tokens"]) || 0
LLM.Usage.new(
input_tokens: u["input_tokens"] || 0,
output_tokens: u["output_tokens"] || 0,
total_tokens: u["total_tokens"] || 0,
cache_read_tokens: cache_read,
cache_write_tokens: 0
)
end
defp extract_streaming_usage(_), do: nil
@impl true
def list_models(config) do
url = config.base_url <> "/v1/models"
req =
Req.new(
url: url,
method: :get,
headers: auth_headers(config, [])
)
case LLM.HTTPClient.request(req) do
{:ok, %Req.Response{status: 200, body: %{"data" => models}}} ->
{:ok, Enum.map(models, &normalize_model/1)}
{:ok, %Req.Response{status: status, body: body}} ->
{:error, %{status: status, body: body}}
{:error, _} = err ->
err
end
end
defp normalize_model(%{"id" => id} = model) do
%{
id: id,
name: model["name"],
description: model["description"],
context_window: model["context_window"],
max_output: model["max_output"],
capabilities: model["capabilities"]
}
end
end