Skip to content

fix(openai): project tool schemas onto the strict-mode subset#175

Merged
raphael merged 2 commits into
mainfrom
fix/openai-strict-schemas
Jul 5, 2026
Merged

fix(openai): project tool schemas onto the strict-mode subset#175
raphael merged 2 commits into
mainfrom
fix/openai-strict-schemas

Conversation

@raphael

@raphael raphael commented Jul 5, 2026

Copy link
Copy Markdown
Member

Problem

Every tool-bearing request through the OpenAI adapter fails with a hard 400 (reported in the community Slack, reproduced with the quickstart):

openai invalid_request 400 (responses.stream): invalid_function_parameters:
Invalid schema for function 'helpers_answer': In context=(),
'additionalProperties' is required to be supplied and to be false.

The adapter registers function tools and structured outputs with strict: true but passes the generated JSON Schema through verbatim. OpenAI strict mode only accepts a constrained schema subset — every object must set additionalProperties: false and list all properties in required, 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.md already states this doctrine for all adapters).

Projection (projectStrictSchema, applied to tool parameters and structured-output schemas):

  • Every object closes with additionalProperties: false; every property becomes required, with canonically optional properties made nullable ("null" added to type unions and enums, $ref properties wrapped in anyOf).
  • Annotations strict mode does not accept are dropped from the provider copy only: $schema, example, examples, default, and format values outside the documented supported set (including Goa's numeric int64/int32 stamps). Constraint keywords (minimum, minLength, pattern, …) stay — current strict-mode docs support them.
  • Contracts strict mode fundamentally cannot represent — open objects and map-style additionalProperties — are rejected with a clear client-side error naming the schema path instead of an opaque provider 400.
  • Recursion is keyword-driven (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": null for 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: encodeTools returns a request-scoped toolCodec (provider↔canonical name maps + canonical schemas) threaded through response translation and streaming in place of the two bare maps; toolInputSchema and the freestanding canonicalToolName helper are deleted.

Verification

  • New table-driven suite covering the projection (annotation stripping, optional→nullable+required, nested objects/arrays, $defs/$ref, anyOf unions, 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).
  • End-to-end adapter test asserting the exact projected parameters sent and the canonicalized tool-call payload returned.
  • Live API check: the previous quickstart schema bytes reproduce the 400 verbatim; the projected schema passes strict-mode validation.
  • make build lint test green (race-enabled suite, lint 0 issues).

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.
Copilot AI review requested due to automatic review settings July 5, 2026 06:07

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 toolCodec that 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.

Comment on lines +262 to +267
if branches, ok := property["anyOf"].([]any); ok {
if !strictBranchesAcceptNull(branches) {
property["anyOf"] = append(branches, map[string]any{"type": strictSchemaTypeNull})
}
return
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@raphael raphael merged commit 390294f into main Jul 5, 2026
2 checks passed
@raphael raphael deleted the fix/openai-strict-schemas branch July 5, 2026 15:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants