# `ConduitMcp.Client.Adapters.OpenAI`
[🔗](https://github.com/nyo16/conduit_mcp/blob/v0.11.0/lib/conduit_mcp/client/adapters/openai.ex#L1)

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 `format`s) 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).

# `api`

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

# `null_path`

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

# `report`

```elixir
@type report() :: %{
  null_paths: %{
    required(ConduitMcp.Client.ToolSet.exposed_name()) =&gt; [null_path()]
  },
  fallbacks: [{ConduitMcp.Client.ToolSet.exposed_name(), strict_error()}],
  excluded: [{ConduitMcp.Client.ToolSet.exposed_name(), strict_error()}]
}
```

# `strict_error`

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

# `strict_schema`

```elixir
@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`

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

Removes `null` values at `null_paths` (as returned by `strict_schema/1`)
from decoded arguments. Only exact `null`s 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`

```elixir
@spec tool_calls(map() | [map()], ConduitMcp.Client.ToolSet.t(), %{
  optional(String.t()) =&gt; [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`

```elixir
@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` - `: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`

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

---

*Consult [api-reference.md](api-reference.md) for complete listing*
