Current section
Files
Jump to
Current section
Files
lib/gen_agent/backends/openai.ex
defmodule GenAgent.Backends.OpenAI do
@moduledoc """
`GenAgent.Backend` implementation backed by the OpenAI Responses API
(`POST /v1/responses`).
Unlike the CLI-backed backends (`GenAgent.Backends.Claude`,
`GenAgent.Backends.Codex`), this backend talks directly to an HTTP
API. Unlike `GenAgent.Backends.Anthropic`, it does **not** resend
the full conversation history on every turn -- OpenAI holds
conversation state server-side, and the backend threads one
`previous_response_id` value across turns.
This makes the OpenAI backend structurally simpler than the
Anthropic one: there's no messages array to manage, and
multi-turn just works as long as `update_session/2` runs after
each terminal `:result` event.
## How the two halves of a turn land in the session
1. `prompt/2` POSTs `{input: [{role: "user", content: prompt}],
previous_response_id: session.previous_response_id, instructions,
store: true}`, parses the response, and returns `{:ok, events,
session}`. The session itself is **not** mutated here -- the
request body is built from `session.previous_response_id` as it
stood entering the turn.
2. When the state machine delivers the terminal `:result` event,
it calls `update_session/2` with the event's data. The event's
`:response_id` becomes `session.previous_response_id` for the
next turn.
3. The next `prompt/2` uses the new `previous_response_id` and
OpenAI replays all prior context on the server side.
## Instructions do not persist across previous_response_id chains
OpenAI's documentation is explicit: "the instructions used on
previous turns will not be present in the context." This backend
therefore resends `:instructions` on **every** request when the
option is set. The per-request token cost is tiny, but the
invariant is load-bearing -- a future optimization that tries to
"save tokens by only sending instructions once" would silently
break system-prompt behavior on every turn after the first.
## Reasoning models
For reasoning models (o1/o3/o4/gpt-5), the response body contains
`output[]` items with `type: "reasoning"` alongside the
`type: "message"` item. This backend ignores the reasoning items
entirely when extracting text -- it walks `output[]` looking for
the message item -- but surfaces `reasoning_tokens` (from
`usage.output_tokens_details.reasoning_tokens`) in the `:usage`
event when present, so patterns can reason about cost.
Set `:reasoning_effort` to `:low`, `:medium`, or `:high` to
request a specific effort level; leave it `nil` for the model
default.
## Options
* `:api_key` -- OpenAI API key. Defaults to `System.get_env("OPENAI_API_KEY")`.
* `:model` -- model name. Defaults to `"gpt-5"`.
* `:instructions` -- system prompt (string). Resent every turn; see note above.
* `:reasoning_effort` -- one of `:low | :medium | :high | nil`.
When set, adds `{"reasoning": {"effort": ...}}` to each request.
* `:max_output_tokens` -- cap on output tokens per turn. Defaults to `nil` (model default).
* `:http_fn` -- a 1-arity function `(request_map) -> {:ok, response_map} | {:error, term}`
that replaces the default `Req`-backed HTTP call. Intended for tests.
"""
@behaviour GenAgent.Backend
alias GenAgent.Event
@endpoint "https://api.openai.com/v1/responses"
@default_model "gpt-5"
defstruct [
:api_key,
:model,
:instructions,
:reasoning_effort,
:max_output_tokens,
:http_fn,
:client_session_id,
:previous_response_id
]
@type reasoning_effort :: :low | :medium | :high | nil
@type t :: %__MODULE__{
api_key: String.t() | nil,
model: String.t(),
instructions: String.t() | nil,
reasoning_effort: reasoning_effort(),
max_output_tokens: pos_integer() | nil,
http_fn: (map() -> {:ok, map()} | {:error, term()}),
client_session_id: String.t(),
previous_response_id: String.t() | nil
}
@impl GenAgent.Backend
def start_session(opts) do
api_key = Keyword.get(opts, :api_key) || System.get_env("OPENAI_API_KEY")
http_fn = Keyword.get(opts, :http_fn, &default_http/1)
session = %__MODULE__{
api_key: api_key,
model: Keyword.get(opts, :model, @default_model),
instructions: Keyword.get(opts, :instructions),
reasoning_effort: Keyword.get(opts, :reasoning_effort),
max_output_tokens: Keyword.get(opts, :max_output_tokens),
http_fn: http_fn,
client_session_id: generate_session_id(),
previous_response_id: nil
}
{:ok, session}
end
@impl GenAgent.Backend
def prompt(%__MODULE__{} = session, prompt) when is_binary(prompt) do
request = build_request(session, prompt)
case session.http_fn.(request) do
{:ok, body} ->
events = response_to_events(body, session.client_session_id)
{:ok, events, session}
{:error, reason} ->
{:error, reason}
end
rescue
e -> {:error, {:http_fn_raised, Exception.message(e)}}
end
@impl GenAgent.Backend
def update_session(%__MODULE__{} = session, %{response_id: response_id})
when is_binary(response_id) and response_id != "" do
%{session | previous_response_id: response_id}
end
def update_session(%__MODULE__{} = session, _data), do: session
@impl GenAgent.Backend
def terminate_session(%__MODULE__{}), do: :ok
# ---------------------------------------------------------------------------
# Request building
# ---------------------------------------------------------------------------
defp build_request(%__MODULE__{} = session, prompt) do
body =
%{
model: session.model,
input: [%{role: "user", content: prompt}],
store: true
}
|> maybe_put(:instructions, session.instructions)
|> maybe_put(:previous_response_id, session.previous_response_id)
|> maybe_put(:max_output_tokens, session.max_output_tokens)
|> maybe_put_reasoning(session.reasoning_effort)
%{
url: @endpoint,
headers: [
{"authorization", "Bearer #{session.api_key || ""}"},
{"content-type", "application/json"}
],
body: body
}
end
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
defp maybe_put_reasoning(map, nil), do: map
defp maybe_put_reasoning(map, effort), do: Map.put(map, :reasoning, %{effort: effort})
# ---------------------------------------------------------------------------
# Response parsing
# ---------------------------------------------------------------------------
defp response_to_events(body, client_session_id) when is_map(body) do
text = extract_text(body)
usage = extract_usage(body)
response_id = body["id"]
status = body["status"]
usage_events =
case usage do
nil -> []
u -> [Event.new(:usage, u)]
end
result_data =
%{
text: text,
session_id: client_session_id,
response_id: response_id,
stop_reason: status,
model: body["model"]
}
|> drop_nil_values()
usage_events ++ [Event.new(:result, result_data)]
end
defp extract_text(%{"output" => output}) when is_list(output) do
output
|> Enum.filter(&match?(%{"type" => "message"}, &1))
|> Enum.flat_map(fn message -> Map.get(message, "content", []) end)
|> Enum.map_join("", fn
%{"type" => "output_text", "text" => text} when is_binary(text) -> text
_ -> ""
end)
end
defp extract_text(_), do: ""
defp extract_usage(%{"usage" => %{} = usage}) do
input = usage["input_tokens"]
output = usage["output_tokens"]
total = usage["total_tokens"]
reasoning = get_in(usage, ["output_tokens_details", "reasoning_tokens"])
case {input, output, total, reasoning} do
{nil, nil, nil, nil} ->
nil
_ ->
%{
input_tokens: input,
output_tokens: output,
total_tokens: total,
reasoning_tokens: reasoning
}
|> drop_nil_values()
end
end
defp extract_usage(_), do: nil
# ---------------------------------------------------------------------------
# HTTP + helpers
# ---------------------------------------------------------------------------
defp default_http(%{url: url, headers: headers, body: body}) do
case Req.post(url, headers: headers, json: body, retry: false) do
{:ok, %Req.Response{status: 200, body: body}} -> {:ok, body}
{:ok, %Req.Response{status: status, body: body}} -> {:error, {:http_error, status, body}}
{:error, reason} -> {:error, reason}
end
end
defp generate_session_id do
"openai-" <>
(:crypto.strong_rand_bytes(8) |> Base.url_encode64(padding: false))
end
defp drop_nil_values(map) do
map
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|> Map.new()
end
end