GenAgentOpenAI

CI Hex.pm Docs

The package source and new issues live in genagent/gen_agent/integrations/openai. The former repository retains historical releases and discussions.

HTTP-direct OpenAI backend for GenAgent, built on Req.

Provides GenAgent.Backends.OpenAI, which talks directly to the OpenAI Responses API (POST /v1/responses) and translates the response into the normalized GenAgent.Event values the state machine consumes.

Unlike the CLI-backed backends (gen_agent_claude, gen_agent_codex), this backend:

Responses API vs Chat Completions

This backend targets the Responses API (/v1/responses), not Chat Completions. The Responses API is OpenAI's newer agent-first primitive and is a much cleaner fit for GenAgent:

If you specifically need Chat Completions, open an issue and we can add GenAgent.Backends.OpenAI.ChatCompletions alongside.

Prerequisites

You need an OpenAI API key. Set OPENAI_API_KEY in your environment, or pass :api_key as a backend option.

Installation

Add gen_agent using the current requirement in the core installation guide.

def deps do
  [{:gen_agent_openai, "~> 0.5.0"}] # x-release-please-version
end

Quick start

defmodule MyApp.Assistant do
  use GenAgent

  defmodule State do
    defstruct responses: []
  end

  @impl true
  def init_agent(_opts) do
    backend_opts = [
      system_prompt: "You are a concise, helpful assistant.",
      max_output_tokens: 512
    ]

    {:ok, backend_opts, %State{}}
  end

  @impl true
  def handle_response(_ref, response, state) do
    {:noreply, %{state | responses: state.responses ++ [response.text]}}
  end
end

{:ok, _pid} = GenAgent.start_agent(MyApp.Assistant,
  name: "my-assistant",
  backend: GenAgent.Backends.OpenAI
)

{:ok, response} = GenAgent.ask("my-assistant", "Explain OTP gen_statem in one sentence.")
IO.puts(response.text)

Session continuation

By default this backend sends store: true. OpenAI stores each response as application state for at least 30 days, so it can be referenced via previous_response_id in the next request. The backend threads one id across turns:

# Turn 1: fresh conversation, no previous_response_id
{:ok, r1} = GenAgent.ask("my-assistant", "Remember the number 42")
# Turn 2: backend sends previous_response_id from r1's terminal event
# (r1.terminal.data.response_id), carried forward automatically
# OpenAI replays turn 1's context on the server side
{:ok, r2} = GenAgent.ask("my-assistant", "What number did I ask you to remember?")
# r2.text =~ "42"

The previous_response_id lives on the session struct and is updated via update_session/2 when each terminal :result event lands.

Set store: false in the backend options to disable response storage:

backend_opts = [system_prompt: "Be concise.", store: false]

The backend then keeps completed input and output items in its local session and resends them with each new prompt. It requests encrypted reasoning content for models that return reasoning items. No previous_response_id is sent in this mode. History grows with each turn, increasing request size and input-token usage; reset the session to discard it. Failed and incomplete turns are not added to history. This setting controls response application-state storage, not the separate abuse-monitoring logs described in OpenAI's data controls. Use store: false explicitly for organizations where OpenAI enforces Zero Data Retention, so the backend uses local continuation.

If the API rejects an established chain because the previous response is unavailable or the context window is exceeded, the failed turn returns {:error, %GenAgent.Backend.Error{kind: :conversation_lost}} with the old {:conversation_lost, body} reason in :raw. The backend clears the saved previous_response_id, so the next prompt starts a fresh conversation without losing the agent process or its application state. It does not retry the failed prompt automatically. Set truncation: "auto" to let the API drop older items before the context window fills; the default is the API's "disabled" behavior. Other HTTP errors retain the chain.

Call GenAgent.reset_session(name) between turns to discard the current previous_response_id or local history and start a fresh conversation on the next prompt. The agent process and its application state remain in place. Reset returns {:error, :busy} during an active turn. Other HTTP failures use the same error struct with :status, :retryable?, and the original Req.Response (including headers) in :raw. The :retry_after field preserves the unparsed Retry-After header on rate limits. Invalid 200 response bodies are labeled :invalid_response, separately from exceptions in an injected :http_fn. When paired with a core version that predates GenAgent.Backend.Error, this adapter retains its earlier tuple reasons.

Instructions do not persist across turns

OpenAI's docs are explicit: instructions from a prior turn do not carry over when you chain via previous_response_id. This backend therefore resends the :system_prompt option as API instructions on every request when the option is set. The per-turn token cost is tiny, but the invariant matters -- a future optimization that "only sends instructions once" would silently break system-prompt behavior on every turn after the first.

Backend options

See GenAgent.Backends.OpenAI for the full module docs. Unknown keys fail session startup with {:unknown_option, key}; conflicting values supplied through aliases fail with {:conflicting_options, keys}.

When the API includes usage.input_tokens_details.cached_tokens, the normalized Response.usage.cached_input_tokens reports that subset of input_tokens. It is not added to the total a second time.

Why no tool use?

This backend is deliberately minimal: text in, text out. The Responses API supports built-in tools (web search, file search, code interpreter) and custom function tools, but adding them means a richer event surface and roundtripping tool results -- better served by a future version or by using gen_agent_claude if you want tool-using agents today.

Testing your agent

Return :http_fn in the backend options from your agent's init_agent/1 (for example, {:ok, Keyword.take(opts, [:http_fn]), initial_state}). Then application tests can use a canned response with no API key or HTTP calls:

body = %{
  "id" => "resp_test", "model" => "test-model", "status" => "completed",
  "output" => [%{"type" => "message", "content" => [%{"type" => "output_text", "text" => "hi"}]}],
  "usage" => %{"input_tokens" => 1, "output_tokens" => 1, "total_tokens" => 2}
}

{:ok, _pid} = GenAgent.start_agent(MyApp.Assistant,
  name: "test-assistant",
  backend: GenAgent.Backends.OpenAI,
  http_fn: fn %{body: _request_body} -> {:ok, body} end
)

{:ok, %GenAgent.Response{text: "hi"}} = GenAgent.ask("test-assistant", "hello")
:ok = GenAgent.stop("test-assistant")

The stub receives a request map and returns the decoded response body. See the keyless primitive examples for callback assertions.

Testing

mix test

Unit tests stub the HTTP layer via the :http_fn backend option, so no tokens are burned during mix test.

Live tests (tagged :live) hit the real API and require OPENAI_API_KEY in the environment:

mix test --only live

License

MIT. See LICENSE.