fix(openai): project tool schemas onto the strict-mode subset#175
Conversation
The adapter registers function tools and structured outputs with
strict:true but passed generated schemas through verbatim. OpenAI strict
mode only accepts a constrained JSON Schema subset, so every tool-bearing
request failed with invalid_function_parameters ("'additionalProperties'
is required to be supplied and to be false").
Give the adapter a strict projection and its exact inverse:
- projectStrictSchema rewrites canonical schemas onto the strict subset:
objects close with additionalProperties:false, every property becomes
required with canonically optional properties made nullable, and
annotations strict mode rejects ($schema, example, examples, default,
unsupported formats) are dropped from the provider copy. Contracts
strict mode cannot represent (open objects, map-style
additionalProperties) are rejected explicitly, mirroring the Bedrock
normalization precedent.
- canonicalizeStrictPayload inverts the one model-visible artifact of the
projection: strict decoding emits explicit null for omitted optional
members, so null members are removed exactly where the canonical schema
does not accept null. Payloads handed to the runtime keep the canonical
encoding of absence and dynamic-registry schema validation keeps
working; members whose contracts accept null pass through untouched.
- encodeTools now returns a request-scoped toolCodec (provider name maps
plus canonical schemas) threaded through response translation and
streaming in place of the bare name maps.
Verified against the live API: the previous schema reproduces the 400
verbatim while the projected schema passes strict-mode validation.
There was a problem hiding this comment.
Pull request overview
This PR fixes OpenAI Responses strict-mode failures by projecting canonical (provider-neutral) JSON Schemas onto OpenAI’s strict subset before sending them, and canonicalizing strict-mode payloads on the way back so downstream validation continues to use the canonical contracts.
Changes:
- Introduces a strict-mode JSON Schema projection (
projectStrictSchema) and an inverse payload canonicalizer (canonicalizeStrictPayload) for tools and structured outputs. - Replaces ad-hoc name maps with a request-scoped
toolCodecthat carries name mappings plus canonical schemas for payload canonicalization. - Updates OpenAI streaming and non-streaming response translation to apply canonicalization for strict-mode tool calls and structured outputs, and documents the behavior in
docs/runtime.md.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| features/model/openai/translate.go | Uses toolCodec and canonicalizes strict-mode tool-call and structured-output payloads before returning them. |
| features/model/openai/tools.go | Adds toolCodec, projects schemas via projectStrictSchema, and threads canonical schemas for inverse canonicalization. |
| features/model/openai/strict_schema.go | Implements strict schema projection + inverse payload canonicalization logic. |
| features/model/openai/strict_schema_test.go | Adds unit tests for projection and inverse canonicalization behavior. |
| features/model/openai/stream.go | Threads toolCodec through streaming translation and uses it for canonical tool names and final tool-call translation. |
| features/model/openai/client.go | Plumbs toolCodec through prepared requests for both Complete and Stream paths. |
| features/model/openai/client_test.go | Adds an end-to-end adapter test asserting projected parameters and canonicalized tool-call arguments. |
| docs/runtime.md | Documents strict schema projection/canonicalization behavior and explicit rejection cases. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if branches, ok := property["anyOf"].([]any); ok { | ||
| if !strictBranchesAcceptNull(branches) { | ||
| property["anyOf"] = append(branches, map[string]any{"type": strictSchemaTypeNull}) | ||
| } | ||
| return | ||
| } |
There was a problem hiding this comment.
Good catch on the gap, though appending a null branch to oneOf would not help by itself: OpenAI strict mode rejects oneOf entirely (only anyOf is accepted), so any schema still carrying oneOf fails validation regardless of nullability. Fixed in 7d955e6 by folding oneOf branches into anyOf during projection — anyOf accepts a superset of the instances oneOf accepts, so the projected schema stays generation-equivalent while the canonical schema keeps exclusive oneOf semantics for local validation. Optional union properties then gain their null branch through the existing anyOf path. Covered by new projection and inverse test cases.
OpenAI strict mode only accepts anyOf, so canonical schemas using oneOf were shipped in a form the provider rejects, and makeStrictNullable had no strict-representable branch list to extend for optional oneOf properties. Fold oneOf branches into anyOf during projection: anyOf accepts a superset of the instances oneOf accepts, so the projected schema stays generation-equivalent while the canonical schema keeps exclusive oneOf semantics for local validation. Optional union properties then gain their null branch through the existing anyOf path.
Problem
Every tool-bearing request through the OpenAI adapter fails with a hard 400 (reported in the community Slack, reproduced with the quickstart):
The adapter registers function tools and structured outputs with
strict: truebut passes the generated JSON Schema through verbatim. OpenAI strict mode only accepts a constrained schema subset — every object must setadditionalProperties: falseand list all properties inrequired, with optionality expressed as a null type union — and the canonical generated schemas satisfy none of that by design (they are provider-neutral).Design
The adapter now owns a strict projection and its exact inverse, following the Bedrock adapter's normalization precedent: produce an equivalent provider schema or reject the contract explicitly (
docs/runtime.mdalready states this doctrine for all adapters).Projection (
projectStrictSchema, applied to tool parameters and structured-output schemas):additionalProperties: false; every property becomes required, with canonically optional properties made nullable ("null"added to type unions and enums,$refproperties wrapped inanyOf).$schema,example,examples,default, andformatvalues outside the documented supported set (including Goa's numericint64/int32stamps). Constraint keywords (minimum,minLength,pattern, …) stay — current strict-mode docs support them.additionalProperties— are rejected with a clear client-side error naming the schema path instead of an opaque provider 400.properties/items/anyOf/$defs/…), so property names that look like keywords ("default","example") are never mistaken for schema keywords.Inverse (
canonicalizeStrictPayload, applied to tool-call arguments and completion payloads):Strict mode forces the model to emit
"field": nullfor omitted optional members, but the canonical payload contract encodes absence as absence — and dynamic-registry tools validate payloads against the canonical schema. The inverse removes null members exactly where the canonical schema does not accept null; by construction those are precisely the members the projection rewrote. No side bookkeeping: the canonical schema fully determines the inverse. Members whose canonical contracts genuinely accept null pass through untouched, and untouched payloads are returned byte-identical.Plumbing:
encodeToolsreturns a request-scopedtoolCodec(provider↔canonical name maps + canonical schemas) threaded through response translation and streaming in place of the two bare maps;toolInputSchemaand the freestandingcanonicalToolNamehelper are deleted.Verification
$defs/$ref,anyOfunions, format filtering, keyword-named properties), the explicit rejections, and the inverse (null removal, null preservation for nullable/undeclared members, ref resolution, union branches, byte-identical passthrough).parameterssent and the canonicalized tool-call payload returned.make build lint testgreen (race-enabled suite, lint 0 issues).