Current section

Files

Jump to
openai_responses lib responses schema.ex
Raw

lib/responses/schema.ex

defmodule OpenAI.Responses.Schema do
@moduledoc """
Helper module for defining structured output schemas and function calling tools.
Converts simple Elixir syntax into JSON Schema format for structured outputs and function parameters.
## Examples
### Structured Output Schema
iex> Responses.Schema.build_output(%{
...> name: {:string, description: "The name of the user"},
...> username: {:string, description: "The username of the user. Must start with @", pattern: "^@[a-zA-Z0-9_]+$"},
...> email: {:string, description: "The email of the user", format: "email"}
...> })
%{
"name" => "data",
"type" => "json_schema",
"strict" => true,
"schema" => %{
"type" => "object",
"properties" => %{
"name" => %{
"type" => "string",
"description" => "The name of the user"
},
"username" => %{
"type" => "string",
"description" => "The username of the user. Must start with @",
"pattern" => "^@[a-zA-Z0-9_]+$"
},
"email" => %{
"type" => "string",
"description" => "The email of the user",
"format" => "email"
}
},
"additionalProperties" => false,
"required" => ["name", "username", "email"]
}
}
### Function Calling Tool
iex> Responses.Schema.build_function("get_weather", "Get current temperature for a given location.", %{
...> location: {:string, description: "City and country e.g. Bogotá, Colombia"}
...> })
%{
"name" => "get_weather",
"type" => "function",
"strict" => true,
"description" => "Get current temperature for a given location.",
"parameters" => %{
"type" => "object",
"properties" => %{
"location" => %{
"type" => "string",
"description" => "City and country e.g. Bogotá, Colombia"
}
},
"additionalProperties" => false,
"required" => ["location"]
}
}
"""
@doc """
Builds a structured output schema from a simple Elixir map or keyword list format.
The input should be a map or keyword list where:
- Keys are field names (atoms)
- Values are either:
- A single atom like `:string`, `:number`, `:boolean`, etc.
- A tuple like `{:string, description: "...", pattern: "..."}`
- For arrays: `{:array, :string}` or `{:array, %{field: :type}}`
When using keyword lists, the order of fields is preserved in the required array.
When using maps, fields are sorted alphabetically in the required array.
"""
def build_output(fields) do
%{
"name" => "data",
"type" => "json_schema",
"strict" => true,
"schema" => build_schema(fields)
}
end
@doc """
Builds a function calling tool schema.
## Parameters
- `name` - The function name
- `description` - A description of what the function does
- `parameters` - A map or keyword list of parameter definitions (same format as `build_output/1`)
## Example
iex> build_function("get_weather", "Get weather for a location", %{
...> location: {:string, description: "City name"},
...> units: {:string, enum: ["celsius", "fahrenheit"], description: "Temperature units"}
...> })
"""
def build_function(name, description, parameters) do
%{
"name" => name,
"type" => "function",
"strict" => true,
"description" => description,
"parameters" => build_schema(parameters)
}
end
defp build_schema(fields) when is_map(fields) or is_list(fields) do
build_property(fields)
end
defp build_property(spec) do
spec
|> normalize_spec()
|> build_from_normalized()
end
# Normalize various input formats to a standard map format
defp normalize_spec(spec) do
cond do
# Simple types
simple_type?(spec) ->
normalize_simple_type(spec)
# Arrays
array_spec?(spec) ->
normalize_array_spec(spec)
# Union types (anyOf)
anyof_spec?(spec) ->
normalize_anyof_spec(spec)
# Type with options
type_with_options?(spec) ->
normalize_type_with_options_spec(spec)
# Lists (keyword lists or empty)
is_list(spec) ->
normalize_list_spec(spec)
# Maps are object specifications
is_map(spec) ->
normalize_map_spec(spec)
# Fallback
true ->
raise ArgumentError, "Unsupported schema specification: #{inspect(spec)}"
end
end
# Type checking helpers
defp simple_type?(type) when is_atom(type) or is_binary(type), do: true
defp simple_type?(_), do: false
defp array_spec?({type, _}) when type in [:array, "array"], do: true
defp array_spec?([type, _]) when type in [:array, "array"], do: true
defp array_spec?(_), do: false
defp anyof_spec?({type, specs}) when type in [:anyOf, "anyOf"] and is_list(specs), do: true
defp anyof_spec?([type, specs]) when type in [:anyOf, "anyOf"] and is_list(specs), do: true
defp anyof_spec?(_), do: false
defp type_with_options?([type, opts]) when (is_atom(type) or is_binary(type)) and (is_list(opts) or is_map(opts)), do: true
defp type_with_options?({type, opts}) when (is_atom(type) or is_binary(type)) and is_list(opts), do: true
defp type_with_options?(_), do: false
# Normalization helpers
defp normalize_simple_type(type) do
%{"type" => to_string(type)}
end
defp normalize_array_spec({_array_type, item_spec}) do
%{"type" => "array", "items" => normalize_spec(item_spec)}
end
defp normalize_array_spec([_array_type, item_spec]) do
%{"type" => "array", "items" => normalize_spec(item_spec)}
end
defp normalize_anyof_spec({_anyof_type, specs}) do
%{"anyOf" => Enum.map(specs, &normalize_spec/1)}
end
defp normalize_anyof_spec([_anyof_type, specs]) do
%{"anyOf" => Enum.map(specs, &normalize_spec/1)}
end
defp normalize_type_with_options_spec([type, opts]) do
opts = normalize_nested_options(opts)
handle_type_with_options(type, opts, &Map.has_key?/2, &Map.get/2)
end
defp normalize_type_with_options_spec({type, opts}) do
handle_type_with_options(type, opts, &Keyword.has_key?/2, &Keyword.get/2)
end
defp handle_type_with_options(type, opts, has_key_fn, get_fn) do
if type in [:object, "object"] and has_key_fn.(opts, :properties) do
properties = get_fn.(opts, :properties)
normalize_object_spec(Enum.to_list(properties))
else
normalize_type_with_options(type, opts)
end
end
defp normalize_list_spec([]) do
normalize_object_spec([])
end
defp normalize_list_spec(list) when is_tuple(hd(list)) do
normalize_object_spec(list)
end
defp normalize_list_spec(spec) do
raise ArgumentError, "Unsupported schema specification: #{inspect(spec)}"
end
defp normalize_map_spec(map) do
map
|> Enum.sort_by(fn {key, _} -> to_string(key) end)
|> normalize_object_spec()
end
# Handle nested list options like [:max_items, 2] -> [max_items: 2]
defp normalize_nested_options(opts) when is_list(opts) do
case opts do
# Single nested list like [:max_items, 2]
[[key, value]] when is_atom(key) or is_binary(key) ->
[{key, value}]
# Direct list like [:max_items, 2] that's not a keyword list
[key, value] when (is_atom(key) or is_binary(key)) and not is_tuple(value) ->
[{key, value}]
# Already a proper keyword list or other format
_ ->
opts
end
end
defp normalize_nested_options(opts), do: opts
defp normalize_type_with_options(type, opts) do
base = %{"type" => to_string(type)}
# Convert options to map if they're a keyword list
opts_map = if is_list(opts), do: Map.new(opts), else: opts
# Merge options into base, converting keys to strings
Enum.reduce(opts_map, base, fn {key, value}, acc ->
Map.put(acc, to_string(key), value)
end)
end
defp normalize_object_spec(spec) do
properties =
spec
|> Enum.map(fn {name, child_spec} ->
{to_string(name), normalize_spec(child_spec)}
end)
|> Map.new()
required =
spec
|> Enum.map(fn {key, _} -> to_string(key) end)
%{
"type" => "object",
"properties" => properties,
"required" => required
}
end
# Build the final property from normalized format
defp build_from_normalized(%{"type" => "array", "items" => items}) do
%{
"type" => "array",
"items" => build_from_normalized(items)
}
end
defp build_from_normalized(%{"anyOf" => specs}) do
%{
"anyOf" => Enum.map(specs, &build_from_normalized/1)
}
end
defp build_from_normalized(%{"type" => "object", "properties" => properties, "required" => required}) do
built_properties =
properties
|> Enum.map(fn {name, prop} -> {name, build_from_normalized(prop)} end)
|> Map.new()
%{
"type" => "object",
"properties" => built_properties,
"additionalProperties" => false,
"required" => required
}
end
defp build_from_normalized(spec) when is_map(spec) do
# Already in final format
spec
end
end