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
case spec do
# Simple types
type when is_atom(type) or is_binary(type) ->
%{"type" => to_string(type)}
# Arrays - both {:array, ...} and {"array", ...}
{array_type, item_spec} when array_type in [:array, "array"] ->
%{"type" => "array", "items" => normalize_spec(item_spec)}
# Arrays in list format - [:array, item_spec]
[array_type, item_spec] when array_type in [:array, "array"] ->
%{"type" => "array", "items" => normalize_spec(item_spec)}
# Lists with exactly 2 elements - treat as [type, options]
[type, opts] when (is_atom(type) or is_binary(type)) and (is_list(opts) or is_map(opts)) ->
if type in [:object, "object"] and is_map(opts) and Map.has_key?(opts, :properties) do
# Special case for [:object, %{properties: ...}]
properties = Map.get(opts, :properties)
normalize_object_spec(Enum.to_list(properties))
else
normalize_type_with_options(type, opts)
end
# Tuples with type and options
{type, opts} when (is_atom(type) or is_binary(type)) and is_list(opts) ->
if type in [:object, "object"] and Keyword.has_key?(opts, :properties) do
# Special case for {:object, properties: ...}
properties = Keyword.get(opts, :properties)
normalize_object_spec(Enum.to_list(properties))
else
normalize_type_with_options(type, opts)
end
# Object specifications - keyword lists or lists of tuples
list when is_list(list) and list != [] and is_tuple(hd(list)) ->
normalize_object_spec(list)
# Empty list
[] ->
normalize_object_spec([])
# Maps are object specifications
map when is_map(map) ->
# Convert to sorted keyword list for consistent ordering
map
|> Enum.sort_by(fn {key, _} -> to_string(key) end)
|> normalize_object_spec()
# Fallback for edge cases
_ ->
raise ArgumentError, "Unsupported schema specification: #{inspect(spec)}"
end
end
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(%{"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