defmodule OpenAgents.Providers.OpenAI do
@moduledoc """
OpenAI Responses API adapter.
OpenAI HTTP, SSE, and event shapes terminate here. Callers receive only
`OpenAgents.Providers.ProviderEvent` values and normalized failure reasons.
"""
@behaviour OpenAgents.Providers.Provider
alias OpenAgents.Providers.OpenAI.StreamDecoder
alias OpenAgents.Providers.{Request, ToolDefinition, ToolOutput}
@endpoint "https://api.openai.com/v1/responses"
@impl true
def id, do: "openai.responses"
@impl true
def capabilities, do: [:text, :tool_calls, :usage]
@impl true
def configured? do
match?({:ok, _key}, OpenAgents.RuntimeConfig.fetch_secret(:openai_api_key))
end
@impl true
def stream(%Request{} = request, on_event) when is_function(on_event, 1) do
stream(request, on_event, [])
end
@doc false
def stream(%Request{} = request, on_event, options)
when is_function(on_event, 1) and is_list(options) do
with {:ok, api_key} <- fetch_api_key(options),
{:ok, response} <- request(api_key, request, options) do
consume_response(response, on_event)
end
end
defp fetch_api_key(options) do
case Keyword.fetch(options, :api_key) do
{:ok, key} when is_binary(key) and byte_size(key) > 0 ->
{:ok, key}
_not_supplied ->
case OpenAgents.RuntimeConfig.fetch_secret(:openai_api_key) do
{:ok, key} -> {:ok, key}
{:error, :not_configured} -> {:error, :missing_api_key}
end
end
end
defp request(api_key, %Request{} = request, options) do
payload = request_payload(request)
request_options = Keyword.get(options, :request_options, [])
base_options = [
auth: {:bearer, api_key},
headers: [{"accept", "text/event-stream"}],
json: payload,
into: :self,
receive_timeout: 120_000,
retry: false
]
case Req.post(@endpoint, Keyword.merge(base_options, request_options)) do
{:ok, response} ->
{:ok, response}
{:error, %Req.TransportError{reason: reason}} when is_atom(reason) ->
{:error, {:transport, reason}}
{:error, _error} ->
{:error, {:transport, :request_failed}}
end
end
@doc false
def request_payload(request) do
%{
model: request.model_id,
instructions: request.instructions,
input: input_items(request),
tools: Enum.map(request.tool_definitions, &tool_definition/1),
parallel_tool_calls: false,
max_output_tokens: request.max_output,
stream: true
}
|> maybe_put(:previous_response_id, request.previous_response_id)
end
# A serial continuation names the response it answers, so the outputs are
# the whole input: the provider already holds the transcript.
defp input_items(%Request{previous_response_id: id} = request) when is_binary(id) do
case request.tool_outputs do
[] -> Enum.map(request.input, &message_item/1)
outputs -> Enum.map(outputs, &tool_output/1)
end
end
# Without a previous response the transcript travels in full, so a prior
# assistant tool call is replayed as its `function_call` item followed by
# the `function_call_output` that answers it — an output without its call
# is an item the Responses API refuses. An output whose call the caller
# did not replay is appended last rather than dropped.
defp input_items(%Request{} = request) do
outputs_by_call_id = Map.new(request.tool_outputs, &{&1.call_id, &1})
replayed_call_ids =
request.input
|> Enum.flat_map(&Map.get(&1, :tool_calls, []))
|> MapSet.new(& &1.call_id)
orphaned = Enum.reject(request.tool_outputs, &MapSet.member?(replayed_call_ids, &1.call_id))
items = Enum.flat_map(request.input, &items_for_message(&1, outputs_by_call_id))
items ++ Enum.map(orphaned, &tool_output/1)
end
defp items_for_message(%{tool_calls: [_call | _rest] = calls} = message, outputs_by_call_id) do
prose = if message.content == "", do: [], else: [message_item(message)]
calls
|> Enum.flat_map(fn call ->
output_items =
case Map.fetch(outputs_by_call_id, call.call_id) do
{:ok, output} -> [tool_output(output)]
:error -> []
end
[function_call_item(call) | output_items]
end)
|> then(&(prose ++ &1))
end
defp items_for_message(message, _outputs_by_call_id), do: [message_item(message)]
defp message_item(%{content: content} = message) when is_list(content) do
%{role: message.role, content: Enum.map(content, &response_content_part/1)}
end
defp message_item(message), do: %{role: message.role, content: message.content}
defp response_content_part(%{type: "text", text: text}),
do: %{type: "input_text", text: text}
defp response_content_part(%{type: "image_url", image_url: %{url: url}}),
do: %{type: "input_image", image_url: url}
defp function_call_item(call) do
%{
type: "function_call",
call_id: call.call_id,
name: call.name,
arguments: call.arguments
}
end
defp tool_definition(%ToolDefinition{} = definition) do
%{
type: "function",
name: definition.name,
description: definition.description,
parameters: definition.input_schema,
strict: definition.strict
}
end
defp tool_output(%ToolOutput{} = output) do
%{
type: "function_call_output",
call_id: output.call_id,
output: Jason.encode!(output.output)
}
end
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
defp consume_response(%Req.Response{status: status, body: body}, on_event)
when status in 200..299 do
body
|> Enum.reduce_while({:ok, StreamDecoder.new()}, fn chunk, {:ok, decoder} ->
case StreamDecoder.feed(decoder, chunk) do
{:ok, next_decoder, events} ->
emit(events, on_event)
{:cont, {:ok, next_decoder}}
{:error, reason} ->
{:halt, {:error, reason}}
end
end)
|> finish(on_event)
rescue
_exception -> {:error, {:transport, :stream_failed}}
end
defp consume_response(%Req.Response{status: status}, _on_event),
do: {:error, {:http_status, status}}
defp finish({:ok, decoder}, on_event) do
case StreamDecoder.finish(decoder) do
{:ok, _decoder, events} ->
emit(events, on_event)
:ok
{:error, reason} ->
{:error, reason}
end
end
defp finish({:error, reason}, _on_event), do: {:error, reason}
defp emit(events, on_event), do: Enum.each(events, on_event)
end