Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 31 additions & 33 deletions lib/req_llm/schema.ex
Original file line number Diff line number Diff line change
Expand Up @@ -204,28 +204,21 @@ defmodule ReqLLM.Schema do
def to_json([]), do: %{"type" => "object", "properties" => %{}}

def to_json(schema) when is_list(schema) do
{properties, required} =
Enum.reduce(schema, {%{}, []}, fn {key, opts}, {props_acc, req_acc} ->
property_ordering = Enum.map(schema, fn {key, _opts} -> to_string(key) end)

properties =
Map.new(schema, fn {key, opts} ->
property_name = to_string(key)
json_prop = nimble_type_to_json_schema(opts[:type] || :string, opts)

new_props = Map.put(props_acc, property_name, json_prop)
new_req = if opts[:required], do: [property_name | req_acc], else: req_acc

{new_props, new_req}
{property_name, json_prop}
end)

schema_object = %{
"type" => "object",
"properties" => properties,
"additionalProperties" => false
}
required =
for {key, opts} <- schema, opts[:required] do
to_string(key)
end

if required == [] do
schema_object
else
Map.put(schema_object, "required", Enum.reverse(required))
end
build_object_schema(properties, required, property_ordering)
end

def to_json(%_{} = schema) when is_struct(schema) do
Expand Down Expand Up @@ -287,6 +280,7 @@ defmodule ReqLLM.Schema do
defp inject_zoi_metadata(%Zoi.Types.Map{meta: meta, fields: fields}, json)
when is_list(fields) do
properties = Map.get(json, :properties) || Map.get(json, "properties") || %{}
property_ordering = Enum.map(fields, fn {key, _field_schema} -> to_string(key) end)

updated_props =
Enum.reduce(fields, %{}, fn {key, field_schema}, acc ->
Expand All @@ -299,6 +293,7 @@ defmodule ReqLLM.Schema do
|> maybe_put_description(meta)
|> Map.put("properties", updated_props)
|> Map.put("additionalProperties", false)
|> maybe_put_nonempty("propertyOrdering", property_ordering)
end

defp inject_zoi_metadata(%Zoi.Types.Array{meta: meta, inner: inner}, json) do
Expand Down Expand Up @@ -447,30 +442,20 @@ defmodule ReqLLM.Schema do
%{"type" => "object"}

{:map, opts} when is_list(opts) and opts != [] ->
property_ordering = Enum.map(opts, fn {key, _prop_opts} -> to_string(key) end)

required_keys =
opts
|> Enum.filter(fn {_key, prop_opts} ->
Keyword.get(prop_opts, :required, false) == true
end)
|> Enum.map(fn {key, _} -> to_string(key) end)
for {key, prop_opts} <- opts, Keyword.get(prop_opts, :required, false) == true do
to_string(key)
end

properties =
Map.new(opts, fn {prop_name, prop_opts} ->
prop_type = Keyword.fetch!(prop_opts, :type)
{to_string(prop_name), nimble_type_to_json_schema(prop_type, prop_opts)}
end)

map_schema = %{
"type" => "object",
"properties" => properties,
"additionalProperties" => false
}

if required_keys == [] do
map_schema
else
Map.put(map_schema, "required", required_keys)
end
build_object_schema(properties, required_keys, property_ordering)

{:map, _} ->
%{"type" => "object"}
Expand Down Expand Up @@ -978,6 +963,19 @@ defmodule ReqLLM.Schema do
defp format_jsv_error(%{"error" => error}), do: error
defp format_jsv_error(error), do: inspect(error)

defp build_object_schema(properties, required, property_ordering) do
%{
"type" => "object",
"properties" => properties,
"additionalProperties" => false
}
|> maybe_put_nonempty("required", required)
|> maybe_put_nonempty("propertyOrdering", property_ordering)
end

defp maybe_put_nonempty(map, _key, []), do: map
defp maybe_put_nonempty(map, key, value), do: Map.put(map, key, value)

@spec deep_delete_keys(term(), [String.t() | atom()]) :: term()
defp deep_delete_keys(map, keys) when is_map(map) do
map
Expand Down
3 changes: 2 additions & 1 deletion test/provider/azure/structured_output_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -582,7 +582,8 @@ defmodule ReqLLM.Providers.Azure.StructuredOutputTest do
kw_params = hd(kw_body[:tools])["function"]["parameters"]
map_params = hd(map_body[:tools])["function"]["parameters"]

assert kw_params == map_params
assert Map.delete(kw_params, "propertyOrdering") == map_params
assert kw_params["propertyOrdering"] == ["name", "age"]
end
end

Expand Down
1 change: 1 addition & 0 deletions test/provider/openai/responses_api_unit_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,7 @@ defmodule Provider.OpenAI.ResponsesAPIUnitTest do
assert body["text"]["format"]["schema"]["properties"]["age"]["type"] == "integer"
assert body["text"]["format"]["schema"]["properties"]["age"]["minimum"] == 1
assert body["text"]["format"]["schema"]["required"] == ["name"]
assert body["text"]["format"]["schema"]["propertyOrdering"] == ["name", "age"]
end

test "encodes response_format with direct JSON schema (pass-through)" do
Expand Down
6 changes: 4 additions & 2 deletions test/provider/openai_structured_output_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -512,8 +512,10 @@ defmodule ReqLLM.Providers.OpenAI.StructuredOutputTest do
kw_schema = ReqLLM.Schema.to_openai_format(keyword_tool)
map_schema_result = ReqLLM.Schema.to_openai_format(map_tool)

# Parameters should be equivalent (ignoring tool names)
assert kw_schema["function"]["parameters"] == map_schema_result["function"]["parameters"]
assert Map.delete(kw_schema["function"]["parameters"], "propertyOrdering") ==
map_schema_result["function"]["parameters"]

assert kw_schema["function"]["parameters"]["propertyOrdering"] == ["name", "age"]
end
end

Expand Down
1 change: 1 addition & 0 deletions test/req_llm/providers/amazon_bedrock/converse_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ defmodule ReqLLM.Providers.AmazonBedrock.ConverseTest do
"properties" => %{
"location" => %{"type" => "string"}
},
"propertyOrdering" => ["location"],
"required" => ["location"],
"additionalProperties" => false
}
Expand Down
38 changes: 36 additions & 2 deletions test/req_llm/schema_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,18 @@ defmodule ReqLLM.SchemaTest do
assert Enum.sort(result["required"]) == ["active", "name"]
end

test "adds propertyOrdering in declared field order" do
schema = [
summary: [type: :string, required: true],
details: [type: :string],
notes: [type: :string]
]

result = Schema.to_json(schema)

assert result["propertyOrdering"] == ["summary", "details", "notes"]
end

test "handles lists with a map subtype" do
tag_schema = {:map, [title: [type: :string, required: true], id: [type: :integer]]}

Expand All @@ -78,6 +90,7 @@ defmodule ReqLLM.SchemaTest do
assert result["properties"]["tags"]["items"]["properties"]["title"]["type"] == "string"
assert result["properties"]["tags"]["items"]["properties"]["id"]["type"] == "integer"
assert result["properties"]["tags"]["items"]["required"] == ["title"]
assert result["properties"]["tags"]["items"]["propertyOrdering"] == ["title", "id"]
end

test "preserves doc option in nested map properties" do
Expand Down Expand Up @@ -325,6 +338,7 @@ defmodule ReqLLM.SchemaTest do
}
},
"required" => ["query"],
"propertyOrdering" => ["query", "limit"],
"additionalProperties" => false
}
}
Expand Down Expand Up @@ -467,7 +481,8 @@ defmodule ReqLLM.SchemaTest do
"description" => "Maximum results"
}
},
"required" => ["query"]
"required" => ["query"],
"propertyOrdering" => ["query", "limit"]
}
}

Expand Down Expand Up @@ -768,6 +783,24 @@ defmodule ReqLLM.SchemaTest do
assert validated == ["a", "b"]
end

test "accepts propertyOrdering as a JSON Schema annotation" do
schema = %{
"type" => "object",
"properties" => %{
"name" => %{"type" => "string"},
"age" => %{"type" => "integer"}
},
"propertyOrdering" => ["name", "age"],
"required" => ["name"],
"additionalProperties" => false
}

data = %{"name" => "Alice", "age" => 30}

assert {:ok, validated} = Schema.validate(data, schema)
assert validated == data
end

test "catches embedded JSON string instead of parsed map for property" do
schema = %{
"type" => "object",
Expand Down Expand Up @@ -1017,7 +1050,8 @@ defmodule ReqLLM.SchemaTest do
keyword_result = Schema.to_json(keyword_schema)
map_result = Schema.to_json(json_schema)

assert keyword_result == json_schema
assert Map.delete(keyword_result, "propertyOrdering") == json_schema
assert keyword_result["propertyOrdering"] == ["location", "units"]
assert map_result == json_schema
end

Expand Down
7 changes: 7 additions & 0 deletions test/req_llm/schema_zoi_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ defmodule ReqLLM.Schema.ZoiTest do
assert result["properties"]["metadata"]["type"] == "object"
assert result["properties"]["metadata"]["properties"]["tags"]["type"] == "array"
assert result["properties"]["metadata"]["properties"]["tags"]["items"]["type"] == "string"
assert Enum.sort(result["propertyOrdering"]) == ["metadata", "user"]
assert Enum.sort(result["properties"]["user"]["propertyOrdering"]) == ["email", "name"]

assert Enum.sort(result["properties"]["metadata"]["propertyOrdering"]) == [
"created_at",
"tags"
]
end

test "converts Zoi array schemas" do
Expand Down
Loading