Converts between a ConduitMcp.Client.ToolSet and OpenAI function tools,
for both the Chat Completions API (api: :chat) and the Responses API
(api: :responses). Pure functions over string-keyed maps; no HTTP.
{toolset, _} = ConduitMcp.Client.Naming.build([{"files", tools}])
{tools, report} = OpenAI.tools(toolset, api: :responses)
# report.fallbacks -> emit [:conduit_mcp, :client, :adapter, :strict_fallback]
{calls, rejected} = OpenAI.tool_calls(response, toolset, report.null_paths)
# run each %ToolCall{} through the client pipeline (or let
# ConduitMcp.Client.Adapters.execute_tool_calls/4 do all of this), then:
outputs = Enum.map(outcomes, fn {call, outcome} ->
OpenAI.tool_output(call.call_id, outcome, api: :responses)
end)
# next input: rejected ++ outputsTool definitions
Chat: %{"type" => "function", "function" => %{"name", "description", "parameters", "strict"}}.
Responses: %{"type" => "function", "name", "description", "parameters", "strict"}.
"strict" is always sent: when it is omitted the Responses API tries
to normalize the schema to strict mode and silently falls back, so the
same tool list would behave differently per API. Names are the toolset's
exposed names (^[A-Za-z0-9_-]{1,64}$, OpenAI's limit). Descriptions have
invisible characters removed and are capped at 4 KiB (PI-1). Only tools in
the toolset are emitted (PO-7).
Strict mode
strict_schema/1 rewrites an MCP inputSchema into the subset OpenAI
accepts with "strict": true, per
https://developers.openai.com/api/docs/guides/structured-outputs#supported-schemas
and https://developers.openai.com/api/docs/guides/function-calling#strict-mode
(checked 2026-10-17):
- The root must be
"type": "object"and must not beanyOf. - Every object gets
"additionalProperties": falseand lists every property inrequired(sorted). A property that was optional is made nullable ("type": [t, "null"],nullappended to anenum, oranyOf: [schema, {"type": "null"}]); its instance path is returned innull_pathssotool_calls/3can drop thenullthe model must now send for it. A property whose schema already allowednullkeeps it. - Kept:
type(string number integer boolean object array null, or a list of them),properties,required,additionalProperties: false,items,enum,const,anyOf, local$ref(#,#/$defs/x,#/definitions/x) with root$defs/definitions,description,title,pattern,minLength,maxLength,multipleOf,minimum,maximum,exclusiveMinimum,exclusiveMaximum,minItems,maxItems, andformatwhen it is one ofdate-time time date duration email hostname ipv4 ipv6 uuid. - Unsupported, so the conversion fails (
{:unsupported, path, keyword}):allOf not oneOf if then else dependentRequired dependentSchemas dependencies patternProperties unevaluatedProperties unevaluatedItems propertyNames prefixItems contains minContains maxContains $dynamicRef $recursiveRef; an open map (additionalPropertiesother thanfalse, or a nested object with neitherpropertiesnoradditionalProperties: false); an array without anitemsschema; a node with notype,enum,const,anyOfor$ref; a non-local or unresolvable$ref; and a recursive$refwhose definitions have optional properties (their null paths would be unbounded). - Everything else (
default,examples,uniqueItems,minProperties,contentMediaType, vendor keywords, otherformats) is dropped: it only narrows or annotates values, and the server still validates. - Limits: at most 5000 properties in total, 10 levels of object nesting
(
$defscount from their own root), 120,000 characters across property names, definition names, enum values and string consts, 1000 enum values, and 15,000 characters for a string enum of more than 250 values. Exceeding one gives{:unsupported, path, limit}withlimitone of:max_properties,:max_depth,:max_chars,:max_enum_values,:max_enum_chars.
With strict: :auto (default) each tool that cannot be converted is sent
with "strict": false and its original schema, and listed in the
report's fallbacks so the runtime can emit
[:conduit_mcp, :client, :adapter, :strict_fallback]. With strict: true
such a tool is left out (listed in excluded), because OpenAI rejects the
whole request otherwise.
Results
Chat Completions tool messages are text only, so media becomes a
placeholder such as "[image: image/png, 1024 bytes]". Responses
function_call_output takes a string, or an array of input_text /
input_image items when the result has an image (JPEG/PNG/GIF/WebP, as a
data URI); other media becomes an input_text placeholder. A result with
is_error: true, or a ConduitMcp.Client.Error, is prefixed with
"Error: " (OpenAI has no error flag).
Summary
Types
Why a schema has no strict form: the schema path and the offending keyword.
Functions
Rewrites a JSON Schema into OpenAI's strict-mode subset.
Removes null values at null_paths (as returned by strict_schema/1)
from decoded arguments. Only exact nulls at those paths are removed.
Extracts the function calls from a response.
Builds the message (Chat) or input item (Responses) answering call_id.
The tools request parameter for toolset, and a report.
Types
@type api() :: :chat | :responses
@type null_path() :: [String.t() | :items]
@type report() :: %{ null_paths: %{ required(ConduitMcp.Client.ToolSet.exposed_name()) => [null_path()] }, fallbacks: [{ConduitMcp.Client.ToolSet.exposed_name(), strict_error()}], excluded: [{ConduitMcp.Client.ToolSet.exposed_name(), strict_error()}] }
@type strict_error() :: {:unsupported, [String.t() | non_neg_integer()], String.t() | atom()}
Why a schema has no strict form: the schema path and the offending keyword.
Functions
@spec strict_schema(term()) :: {:ok, map(), [null_path()]} | {:error, strict_error()}
Rewrites a JSON Schema into OpenAI's strict-mode subset.
Returns {:ok, strict_schema, null_paths}, where each null path is the
list of property names (and :items for "every array element") leading
to a property that was optional and is now nullable, or
{:error, {:unsupported, schema_path, keyword_or_limit}}. The rules are
in the moduledoc.
iex> schema = %{
...> "type" => "object",
...> "properties" => %{"q" => %{"type" => "string"}, "limit" => %{"type" => "integer"}},
...> "required" => ["q"]
...> }
iex> {:ok, strict, paths} = ConduitMcp.Client.Adapters.OpenAI.strict_schema(schema)
iex> strict["properties"]["limit"]
%{"type" => ["integer", "null"]}
iex> {strict["required"], strict["additionalProperties"], paths}
{["limit", "q"], false, [["limit"]]}
iex> ConduitMcp.Client.Adapters.OpenAI.strict_schema(%{
...> "type" => "object",
...> "properties" => %{"v" => %{"oneOf" => [%{"type" => "string"}]}}
...> })
{:error, {:unsupported, ["properties", "v"], "oneOf"}}
Removes null values at null_paths (as returned by strict_schema/1)
from decoded arguments. Only exact nulls at those paths are removed.
iex> ConduitMcp.Client.Adapters.OpenAI.strip_nulls(
...> %{"q" => "x", "limit" => nil, "tags" => [%{"k" => nil}]},
...> [["limit"], ["tags", :items, "k"]]
...> )
%{"q" => "x", "tags" => [%{}]}
@spec tool_calls(map() | [map()], ConduitMcp.Client.ToolSet.t(), %{ optional(String.t()) => [null_path()] }) :: {[ConduitMcp.Client.ToolCall.t()], [map()]}
Extracts the function calls from a response.
Accepts a Chat Completion (the first choice's message is used), a Chat
assistant message ("tool_calls"), a Responses response ("output"), or
a list of Chat tool_calls entries or Responses output items. Each
function call whose name resolves in toolset becomes a
ConduitMcp.Client.ToolCall with call_id set to the Chat id or the
Responses call_id. Its arguments JSON is decoded (an empty string
reads as {}) and the nulls strict mode introduced are removed using
null_paths_by_tool (the null_paths from tools/2).
A call naming a tool not in the toolset, or with arguments that are not a
JSON object, produces no ToolCall (HB-2). Instead it yields an error
output in the same API's shape, returned in the second list, to send back
to the model. Other output items (messages, reasoning) are ignored.
@spec tool_output( String.t(), ConduitMcp.Client.ToolResult.t() | ConduitMcp.Client.Error.t(), keyword() ) :: map()
Builds the message (Chat) or input item (Responses) answering call_id.
Options
:api-:chator:responses(required).:error_prefix- prepended to the text of an error result (default"Error: ").:structured- howstructuredContentis used::auto(default, only whencontentis empty),:prefer,:appendor:ignore. It is rendered as JSON text.
@spec tools(ConduitMcp.Client.ToolSet.t(), keyword()) :: {[map()], report()}
The tools request parameter for toolset, and a report.
The report has null_paths (exposed name → paths, for every tool sent
with "strict": true; pass it to tool_calls/3), fallbacks (tools sent
non-strict under strict: :auto, with the conversion error) and
excluded (tools left out under strict: true).
Options
:api-:chator:responses(required).:strict-:auto(default),trueorfalse.