Current section

Files

Jump to
responses lib responses.ex
Raw

lib/responses.ex

defmodule Responses do
@moduledoc """
Client for the Responses API.
This module provides a simple interface for creating AI responses with support for:
- Text and structured output generation
- Streaming responses with Server-Sent Events (SSE)
- Automatic cost calculation for all API calls
- JSON Schema-based structured outputs
## Available Functions
- `create/1` and `create/2` - Create AI responses (synchronous or streaming)
- `create!/1` and `create!/2` - Same as create but raises on error
- `run/2` and `run!/2` - Run conversations with automatic function calling
- `stream/1` - Stream responses as an Enumerable
- `list_models/1` and `list_models/2` - List available models for a provider
- `request/1` - Low-level API request function
## Configuration
Set your OpenAI API key via:
- Environment variable: `OPENAI_API_KEY`
- Application config: `config :responses, :openai_api_key, "your-key"`
## Examples
See the [tutorial](tutorial.livemd) for comprehensive examples and usage patterns.
"""
@default_receive_timeout 60_000
alias Responses
alias Responses.Error
alias Responses.Internal
alias Responses.Options
alias Responses.Provider
alias Responses.Response
@typedoc "User-facing options accepted by create/1, stream/1, run/2"
@type options_input :: map() | keyword()
@typedoc "Result from low-level request or create/1"
@type result :: {:ok, Response.t()} | {:error, term()}
@doc """
Create a new response.
When the argument is a string, it is used as the input text.
Otherwise, the argument is expected to be a keyword list or map of options that OpenAI expects,
such as `input`, `model`, `temperature`, `max_tokens`, etc.
## LLM Options Preservation with previous_response_id
The OpenAI API always requires a model parameter, even when using `previous_response_id`.
When using `create/1` with manual `previous_response_id`:
- If no model is specified, the default model is used
- LLM options (model, text, reasoning) from the previous response are NOT automatically inherited
When using `create/2` with a Response object:
- Only these options are preserved from the previous response:
- `model`
- `reasoning.effort`
- `text.verbosity`
- Text format/schema (`text.format` or `schema:` option) is never preserved; specify a new `schema:` if needed
- You can override any preserved option by explicitly providing a value
# Manual previous_response_id - uses defaults if not specified
Responses.create(input: "Hello", previous_response_id: "resp_123")
# Manual previous_response_id - with explicit options
Responses.create(input: "Hello", previous_response_id: "resp_123", model: "gpt-4.1")
# Using create/2 - automatically inherits LLM options from previous response
Responses.create(previous_response, input: "Hello")
# Using create/2 - with reasoning effort preserved (requires model that supports reasoning)
first = Responses.create!(input: "Question", model: "gpt-5-mini", reasoning: %{effort: "high"})
followup = Responses.create!(first, input: "Follow-up") # Inherits gpt-5-mini and high reasoning
## Examples
# Using a keyword list
Responses.create(input: "Hello", model: "gpt-4.1", temperature: 0.7)
# Using a map
Responses.create(%{input: "Hello", model: "gpt-4.1", temperature: 0.7})
# String shorthand
Responses.create("Hello")
## Structured Output with :schema
Pass a `schema:` option to get structured JSON output from the model.
The schema is defined using a simple Elixir syntax that is converted to JSON Schema format.
Both maps and keyword lists with atom or string keys are accepted for all options:
# Using a map with atom keys
Responses.create(%{
input: "Extract user info from: John Doe, username @johndoe, john@example.com",
schema: %{
name: :string,
username: {:string, pattern: "^@[a-zA-Z0-9_]+$"},
email: {:string, format: "email"}
}
})
# Using a keyword list
Responses.create(
input: "Extract product details",
schema: [
product_name: :string,
price: :number,
in_stock: :boolean,
tags: {:array, :string}
]
)
# Arrays at the root level (new in 0.6.0)
Responses.create(
input: "List 3 US presidents with facts",
schema: {:array, %{
name: :string,
birth_year: :integer,
achievements: {:array, :string}
}}
)
# Returns an array directly in response.parsed
# Mixed keys (atoms and strings) are supported
Responses.create(%{
"input" => "Analyze this data",
:schema => %{
"result" => :string,
:confidence => :number
}
})
The response will include a `parsed` field with the extracted structured data.
See `Responses.Schema` for the full schema syntax documentation.
## Streaming
Pass a `stream:` option with a callback function to stream the response.
The callback receives results wrapped in `{:ok, chunk}` or `{:error, reason}` tuples:
Responses.create(
input: "Write a story",
stream: fn
{:ok, %{event: "response.output_text.delta", data: %{"delta" => text}}} ->
IO.write(text)
:ok
{:error, reason} ->
IO.puts("Stream error: \#{inspect(reason)}")
:ok # Continue despite errors
_ ->
:ok
end
)
The callback should return `:ok` to continue or `{:error, reason}` to stop the stream.
For simpler text streaming, use the `delta/1` helper:
Responses.create(
input: "Write a story",
stream: Responses.Stream.delta(&IO.write/1)
)
If no model is specified, the default model is used.
"""
@spec create(options_input) :: result
def create(options) when is_list(options) or is_map(options) do
normalized = Options.normalize(options)
{stream_callback, normalized} = Map.pop(normalized, "stream")
result =
if stream_callback do
Responses.Stream.stream_with_callback(stream_callback, normalized)
else
{payload, provider} = build_request(normalized)
request(
provider: provider,
url: "/responses",
json: payload,
method: :post
)
end
with {:ok, response} <- result do
{:ok, response |> ensure_body_map() |> process_response}
end
end
def create(input) when is_binary(input) do
IO.warn(
"Passing a binary to Responses.create/1 is deprecated. Use create(input: \"...\") or options map/keyword."
)
create(input: input)
end
# Define exactly which options should be preserved across chained responses.
# We only preserve:
# - model name
# - reasoning effort
# - text verbosity
# Note: We explicitly do NOT preserve any text.format/schema between calls.
@preserved_llm_paths [["model"], ["reasoning", "effort"], ["text", "verbosity"]]
@doc """
Create a response based on a previous response.
This allows creating follow-up responses that maintain context from a previous response.
The previous response's ID is automatically included in the request.
Options can be provided as either a keyword list or a map.
## Preserved Options
The following options are automatically preserved from the previous response unless explicitly overridden:
- `model` - The model used for generation
- `text` - Text generation settings (including verbosity)
- `reasoning` - Reasoning settings (including effort level)
## Examples
{:ok, first} = Responses.create("What is Elixir?")
# Using keyword list
{:ok, followup} = Responses.create(first, input: "Tell me more about its concurrency model")
# Using map
{:ok, followup} = Responses.create(first, %{input: "Tell me more about its concurrency model"})
# With reasoning effort preserved (requires model that supports reasoning)
{:ok, first} = Responses.create(input: "Complex question", model: "gpt-5-mini", reasoning: %{effort: "high"})
{:ok, followup} = Responses.create(first, input: "Follow-up") # Inherits gpt-5-mini and high reasoning effort
"""
@spec create(Response.t(), options_input) :: result
def create(%Response{} = previous_response, options) when is_list(options) or is_map(options) do
# Normalize to map with string keys for consistent handling
options_map = Options.normalize(options)
user_supplied_options = options_map
previous_body =
case previous_response.body do
%{} = body -> body
_ -> %{}
end
# Add previous_response_id
options_map =
case Map.get(previous_body, "id") do
nil -> options_map
id -> Map.put(options_map, "previous_response_id", id)
end
# Preserve LLM options from the previous response if not explicitly provided
options_map = preserve_llm_options(options_map, previous_body)
# Remove any provider-unsupported options that were preserved automatically
options_map =
filter_auto_preserved_unsupported_options(
options_map,
previous_response,
previous_body,
user_supplied_options
)
create(options_map)
end
# Helper to preserve selected LLM options from previous response
defp preserve_llm_options(options_map, previous_body) do
Options.preserve_paths(options_map, previous_body, @preserved_llm_paths)
end
defp filter_auto_preserved_unsupported_options(
options_map,
%Response{} = previous_response,
previous_body,
user_supplied_options
) do
provider = resolve_target_provider(previous_response, previous_body)
case provider do
%Provider.Info{unsupported_options: unsupported} when unsupported != [] ->
paths = Enum.map(unsupported, fn {path, _message} -> path end)
Options.drop_preserved_paths(options_map, previous_body, user_supplied_options, paths)
_ ->
options_map
end
end
defp resolve_target_provider(%Response{provider: %Provider.Info{} = provider}, _previous_body),
do: provider
defp resolve_target_provider(_response, previous_body) do
provider_from_model(previous_body)
end
defp provider_from_model(previous_body) do
with model when is_binary(model) <- Map.get(previous_body, "model"),
{:ok, provider, _canonical} <- Provider.resolve_model(model) do
provider
else
_ -> nil
end
end
@doc """
Same as `create/1` but raises an error on failure.
Returns the response directly instead of an {:ok, response} tuple.
## Examples
response = Responses.create!("Hello, world!")
IO.puts(response.text)
"""
@spec create!(options_input) :: Response.t()
def create!(options) do
case create(options) do
{:ok, response} -> response
{:error, error} -> raise error
end
end
@doc """
Same as `create/2` but raises an error on failure.
Returns the response directly instead of an {:ok, response} tuple.
## Examples
first = Responses.create!("What is Elixir?")
followup = Responses.create!(first, input: "Tell me more")
"""
@spec create!(Response.t(), options_input) :: Response.t()
def create!(%Response{} = previous_response, options) do
case create(previous_response, options) do
{:ok, response} -> response
{:error, error} -> raise error
end
end
@doc """
Stream a response from the OpenAI API as an Enumerable.
Returns a Stream that yields chunks with `event` and `data` keys.
Options can be provided as either a keyword list or a map.
## Examples
# Stream and handle all results
for result <- Responses.stream("Tell me a story") do
case result do
{:ok, chunk} -> IO.inspect(chunk)
{:error, reason} -> IO.puts("Error: \#{inspect(reason)}")
end
end
# Process only text deltas, ignoring errors
Responses.stream("Write a poem")
|> Stream.filter(fn
{:ok, %{event: "response.output_text.delta"}} -> true
_ -> false
end)
|> Stream.map(fn {:ok, chunk} -> chunk.data["delta"] end)
|> Enum.each(&IO.write/1)
# Accumulate all text with error handling (using map)
result = Responses.stream(%{input: "Explain quantum physics"})
|> Enum.reduce(%{text: "", errors: []}, fn
{:ok, %{event: "response.output_text.delta", data: %{"delta" => delta}}}, acc ->
%{acc | text: acc.text <> delta}
{:error, reason}, acc ->
%{acc | errors: [reason | acc.errors]}
_, acc ->
acc
end)
"""
@spec stream(options_input | String.t()) :: Enumerable.t()
def stream(options) when is_list(options) or is_map(options) do
Responses.Stream.stream(options)
end
def stream(input) when is_binary(input) do
IO.warn(
"Passing a binary to Responses.stream/1 is deprecated. Use stream(input: \"...\") or options map/keyword."
)
stream(input: input)
end
@doc """
List available models for a provider.
Accepts a provider identifier (atom, string, or `Responses.Provider.Info` struct) and an
optional `match` string to filter by model ID.
"""
@spec list_models(Provider.id() | String.t() | Provider.Info.t()) :: [map()]
def list_models(provider) do
list_models(provider, "")
end
@spec list_models(Provider.id() | String.t() | Provider.Info.t(), String.t()) :: [map()]
def list_models(provider, match) when is_binary(match) do
provider = ensure_provider_struct(provider)
{:ok, response} =
request(
provider: provider,
url: "/models"
)
response.body["data"]
|> Enum.filter(&(&1["id"] =~ match))
end
@doc """
Run a conversation with automatic function calling.
This function automates the process of handling function calls by repeatedly calling the
provided functions and feeding their results back to the model until a final response
without function calls is received.
## Parameters
- `options` - Keyword list or map of options to pass to `create/1`
- `functions` - A map or keyword list where:
- Keys are function names (as atoms or strings)
- Values are functions that accept the parsed arguments and return the result
## Returns
Returns a list of all responses generated during the conversation, in chronological order.
The last response in the list will be the final answer without function calls.
## Examples
# Define available functions
functions = %{
"get_weather" => fn %{"location" => location} ->
# Simulate weather API call
"The weather in \#{location} is 72°F and sunny"
end,
"get_time" => fn %{} ->
DateTime.utc_now() |> to_string()
end
}
# Create function tools
weather_tool = Responses.Schema.build_function(
"get_weather",
"Get current weather for a location",
%{location: :string}
)
time_tool = Responses.Schema.build_function(
"get_time",
"Get the current UTC time",
%{}
)
# Run the conversation (with keyword list)
responses = Responses.run(
[input: "What's the weather in Paris and what time is it?",
tools: [weather_tool, time_tool]],
functions
)
# Or with map
responses = Responses.run(
%{input: "What's the weather in Paris and what time is it?",
tools: [weather_tool, time_tool]},
functions
)
# The last response contains the final answer
final_response = List.last(responses)
IO.puts(final_response.text)
"""
@spec run(options_input, map() | keyword()) :: [Response.t()] | {:error, term()}
def run(options, functions)
when is_list(options) and (is_map(functions) or is_list(functions)) do
case do_run(options, functions, []) do
responses when is_list(responses) -> Enum.reverse(responses)
error -> error
end
end
def run(options, functions)
when is_map(options) and (is_map(functions) or is_list(functions)) do
# Convert map to list for processing
options_list = Map.to_list(options)
run(options_list, functions)
end
defp do_run(options, functions, responses) do
case create(options) do
{:ok, response} ->
handle_response(response, functions, responses)
{:error, _} = error ->
error
end
end
defp do_run(%Response{} = previous_response, options, functions, responses) do
case create(previous_response, options) do
{:ok, response} ->
handle_response(response, functions, responses)
{:error, _} = error ->
error
end
end
defp handle_response(response, functions, responses) do
responses = [response | responses]
case response.function_calls do
nil ->
# No function calls, return all responses
responses
[] ->
# Empty function calls, return all responses
responses
calls ->
# Process function calls and continue
outputs_opts =
Responses.Prompt.add_function_outputs(%{input: []}, calls, functions)
# Continue conversation with function results using the latest response
do_run(response, Map.to_list(outputs_opts), functions, responses)
end
end
@doc """
Same as `run/2` but raises an error on failure.
Returns the list of responses directly instead of an {:ok, responses} tuple.
"""
@spec run!(options_input, map() | keyword()) :: [Response.t()]
def run!(options, functions) do
case run(options, functions) do
responses when is_list(responses) -> responses
{:error, reason} -> raise "Function calling failed: #{inspect(reason)}"
end
end
@doc """
Request a response from the OpenAI API.
Used as a building block by other functions in this module.
Accepts that same arguments as `Req.request/1`.
You should provide `url`, `json`, `method`, and other options as needed.
"""
@spec request(keyword() | map()) :: result
def request(options) when is_list(options) do
{provider, req_options} = Keyword.pop(options, :provider)
provider = ensure_provider_struct(provider)
req =
Req.new(
base_url: provider.base_url,
receive_timeout: @default_receive_timeout
)
|> put_auth(provider)
|> Req.merge(req_options)
do_request(req, provider)
end
def request(%{} = options) do
provider = Map.fetch!(options, :provider)
rest = options |> Map.delete(:provider) |> Map.to_list()
request([provider: provider] ++ rest)
end
# Process a response by extracting text, JSON, function calls, and calculating cost
defp process_response(response) do
response
|> Response.extract_json()
|> Response.extract_function_calls()
|> Response.calculate_cost()
end
defp ensure_body_map(%Response{body: body} = response) when is_map(body), do: response
defp ensure_body_map(%Response{} = response), do: %{response | body: %{}}
@doc false
@spec build_request(map()) :: {map(), Provider.Info.t()}
def build_request(options) when is_map(options) do
{warnings_pref, options} = pop_provider_warning_pref(options)
payload = Internal.prepare_payload(options)
{payload, provider} = Provider.assign_model(payload)
{payload, converted?} = maybe_convert_xai_developer_roles(payload, provider)
if converted? and Provider.warning_mode(warnings_pref) == :warn do
IO.warn(
"xAI does not support `role: :developer`. Converted affected messages to `:system`. Update your prompts to silence this warning."
)
end
Provider.warn_on_unsupported(provider, options, warnings_pref)
{payload, provider}
end
defp pop_provider_warning_pref(options) do
{pref, remainder} = Map.pop(options, "provider_warnings")
case pref do
nil -> Map.pop(remainder, :provider_warnings)
_ -> {pref, remainder}
end
end
defp ensure_provider_struct(nil), do: Provider.get!(:openai)
defp ensure_provider_struct(%Provider.Info{} = provider), do: provider
defp ensure_provider_struct(identifier), do: Provider.get!(identifier)
defp do_request(req, provider) do
{duration_us, result} = :timer.tc(fn -> Req.request(req) end)
emit_request_telemetry(provider, req, result, duration_us)
case result do
{:ok, %Req.Response{status: 200, body: body}} ->
{:ok, %Response{body: body, provider: provider}}
{:ok, resp = %Req.Response{}} ->
{:error, Error.from_response(resp)}
{_status, other} ->
{:error, other}
end
end
defp put_auth(req, provider) do
api_key = Provider.fetch_api_key(provider)
Req.merge(req, auth: {:bearer, api_key})
end
defp emit_request_telemetry(provider, req, result, duration_us) do
measurements = %{duration: duration_us}
method = Map.get(req, :method) || Map.get(req.options, :method) || :get
metadata = %{
provider: provider.id,
provider_name: provider.name,
method: method,
url: Map.get(req, :url),
result: telemetry_result_tag(result)
}
:telemetry.execute([:responses, :request, :stop], measurements, metadata)
rescue
_ -> :ok
end
defp telemetry_result_tag({:ok, %Req.Response{status: status}}) when status in 200..299, do: :ok
defp telemetry_result_tag({:ok, %Req.Response{status: status}}), do: {:http_error, status}
defp telemetry_result_tag({:error, error}), do: {:error, error}
defp telemetry_result_tag(other), do: {:unknown, other}
defp maybe_convert_xai_developer_roles(payload, %Provider.Info{id: :xai} = _provider) do
case Map.get(payload, "input") do
messages when is_list(messages) ->
{converted_messages, changed?} = convert_developer_messages(messages)
if changed? do
{Map.put(payload, "input", converted_messages), true}
else
{payload, false}
end
_ ->
{payload, false}
end
end
defp maybe_convert_xai_developer_roles(payload, _provider), do: {payload, false}
defp convert_developer_messages(messages) do
Enum.map_reduce(messages, false, fn message, changed_acc ->
{converted, changed?} = convert_developer_message(message)
{converted, changed_acc or changed?}
end)
end
defp convert_developer_message(%{} = message) do
if developer_role_message?(message) do
{apply_role_conversion(message), true}
else
{message, false}
end
end
defp convert_developer_message(message), do: {message, false}
defp developer_role_message?(message) do
has_role? = role_value(message) in [:developer, "developer"]
has_role? and not Map.has_key?(message, "type") and not Map.has_key?(message, :type)
end
defp role_value(message) do
Map.get(message, "role") || Map.get(message, :role)
end
defp apply_role_conversion(message) do
message
|> maybe_put_role("role")
|> maybe_put_role(:role)
end
defp maybe_put_role(message, key) do
case Map.fetch(message, key) do
{:ok, :developer} -> Map.put(message, key, :system)
{:ok, "developer"} -> Map.put(message, key, "system")
_ -> message
end
end
end