ConduitMcp.Client.Adapters.OpenAI (ConduitMCP v0.11.0)

Copy Markdown View Source

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 ++ outputs

Tool 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 be anyOf.
  • Every object gets "additionalProperties": false and lists every property in required (sorted). A property that was optional is made nullable ("type": [t, "null"], null appended to an enum, or anyOf: [schema, {"type": "null"}]); its instance path is returned in null_paths so tool_calls/3 can drop the null the model must now send for it. A property whose schema already allowed null keeps 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, and format when it is one of date-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 (additionalProperties other than false, or a nested object with neither properties nor additionalProperties: false); an array without an items schema; a node with no type, enum, const, anyOf or $ref; a non-local or unresolvable $ref; and a recursive $ref whose definitions have optional properties (their null paths would be unbounded).
  • Everything else (default, examples, uniqueItems, minProperties, contentMediaType, vendor keywords, other formats) 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 ($defs count 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} with limit one 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

api()

@type api() :: :chat | :responses

null_path()

@type null_path() :: [String.t() | :items]

report()

@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()}]
}

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

strict_schema(schema)

@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"}}

strip_nulls(args, null_paths)

@spec strip_nulls(term(), [null_path()]) :: term()

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" => [%{}]}

tool_calls(response, toolset, null_paths_by_tool \\ %{})

@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.

tool_output(call_id, result, opts)

Builds the message (Chat) or input item (Responses) answering call_id.

Options

  • :api - :chat or :responses (required).
  • :error_prefix - prepended to the text of an error result (default "Error: ").
  • :structured - how structuredContent is used: :auto (default, only when content is empty), :prefer, :append or :ignore. It is rendered as JSON text.

tools(toolset, opts)

@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 - :chat or :responses (required).
  • :strict - :auto (default), true or false.