Skip to content

Commit c55764a

Browse files
fix(openai): emit strict:false for function tools whose schema is outside OpenAI's strict subset (#786)
* fix(openai): emit strict:false for tools whose schema is outside OpenAI's strict subset The Responses and Chat Completions tool converters forced `strict: true` on every function tool. When a tool's schema uses keywords OpenAI's strict Structured Outputs subset doesn't support (oneOf/allOf/not/$ref/$defs — routinely emitted by MCP servers such as Notion), the API rejected the entire request with `400 Invalid schema for function '…'`, breaking every run that included such a tool. Detect schemas outside the strict subset (`isStrictModeCompatible`) and emit those tools with `strict: false` — the schema is passed through (only unsupported `format` keywords stripped) so the tool stays callable. Schemas that fit the strict subset keep `strict: true` and the existing coercion. * ci: apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent e441a6f commit c55764a

6 files changed

Lines changed: 280 additions & 6 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@tanstack/openai-base': patch
3+
---
4+
5+
fix(openai): emit `strict: false` for function tools whose JSON Schema is outside OpenAI's strict subset
6+
7+
The Responses and Chat Completions tool converters forced `strict: true` on
8+
every function tool. When a tool's schema uses keywords OpenAI's strict
9+
Structured Outputs subset doesn't support (`oneOf`/`allOf`/`not`/`$ref`/
10+
`$defs` — routinely emitted by MCP servers such as Notion), the API rejected
11+
the **entire** request with `400 Invalid schema for function '…'`, breaking
12+
every run that included such a tool.
13+
14+
These converters now detect schemas outside the strict subset
15+
(`isStrictModeCompatible`) and emit those tools with `strict: false` — the
16+
schema is passed through (only unsupported `format` keywords are stripped) so
17+
the tool stays callable. Schemas that fit the strict subset keep `strict: true`
18+
and the existing structured-output coercion, so well-behaved tools are
19+
unaffected.

packages/openai-base/src/adapters/chat-completions-tool-converter.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { makeStructuredOutputCompatible } from '../utils/schema-converter'
1+
import {
2+
isStrictModeCompatible,
3+
makeStructuredOutputCompatible,
4+
stripUnsupportedFormats,
5+
} from '../utils/schema-converter'
26
import type { ChatCompletionTool } from 'openai/resources/chat/completions/completions'
37
import type { JSONSchema, Tool } from '@tanstack/ai'
48

@@ -22,7 +26,14 @@ export type ChatCompletionFunctionTool = Extract<
2226
* - Optional fields made nullable
2327
* - additionalProperties: false
2428
*
25-
* This enables strict mode for all tools automatically.
29+
* This enables strict mode for tools whose schemas fit OpenAI's strict subset.
30+
*
31+
* Schemas using keywords outside that subset (`oneOf`/`allOf`/`not`/`$ref`/
32+
* `$defs` — common with MCP servers like Notion) can't be coerced to a
33+
* strict-valid shape, and `strict: true` would make the API reject the ENTIRE
34+
* request with a 400. Such tools are emitted with `strict: false` (their schema
35+
* passed through, only unsupported `format` keywords stripped) so they stay
36+
* callable.
2637
*/
2738
export function convertFunctionToolToChatCompletionsFormat(
2839
tool: Tool,
@@ -37,6 +48,20 @@ export function convertFunctionToolToChatCompletionsFormat(
3748
required: [],
3849
}) as JSONSchema
3950

51+
// Schema outside OpenAI's strict subset: send non-strict so the tool still
52+
// works instead of 400-ing the whole request.
53+
if (!isStrictModeCompatible(inputSchema)) {
54+
return {
55+
type: 'function',
56+
function: {
57+
name: tool.name,
58+
description: tool.description,
59+
parameters: stripUnsupportedFormats(inputSchema),
60+
strict: false,
61+
},
62+
} satisfies ChatCompletionFunctionTool
63+
}
64+
4065
// Shallow-copy the converter's result before mutating: a subclass-supplied
4166
// schemaConverter has no contract requirement to return a fresh object,
4267
// and a passthrough `(s) => s` would otherwise have its caller's schema

packages/openai-base/src/adapters/responses-tool-converter.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { makeStructuredOutputCompatible } from '../utils/schema-converter'
1+
import {
2+
isStrictModeCompatible,
3+
makeStructuredOutputCompatible,
4+
stripUnsupportedFormats,
5+
} from '../utils/schema-converter'
26
import type { JSONSchema, Tool } from '@tanstack/ai'
37

48
/**
@@ -28,7 +32,14 @@ export interface ResponsesFunctionTool {
2832
* - Optional fields made nullable
2933
* - additionalProperties: false
3034
*
31-
* This enables strict mode for all tools automatically.
35+
* This enables strict mode for tools whose schemas fit OpenAI's strict subset.
36+
*
37+
* Schemas using keywords outside that subset (`oneOf`/`allOf`/`not`/`$ref`/
38+
* `$defs` — common with MCP servers like Notion) can't be coerced to a
39+
* strict-valid shape, and `strict: true` would make the Responses API reject
40+
* the ENTIRE request with a 400. Such tools are emitted with `strict: false`
41+
* (their schema passed through, only unsupported `format` keywords stripped) so
42+
* they stay callable.
3243
*/
3344
export function convertFunctionToolToResponsesFormat(
3445
tool: Tool,
@@ -43,6 +54,18 @@ export function convertFunctionToolToResponsesFormat(
4354
required: [],
4455
}) as JSONSchema
4556

57+
// Schema outside OpenAI's strict subset: send non-strict so the tool still
58+
// works instead of 400-ing the whole request.
59+
if (!isStrictModeCompatible(inputSchema)) {
60+
return {
61+
type: 'function',
62+
name: tool.name,
63+
description: tool.description,
64+
parameters: stripUnsupportedFormats(inputSchema),
65+
strict: false,
66+
}
67+
}
68+
4669
// Shallow-copy the converter's result before mutating — a subclass-supplied
4770
// schemaConverter has no contract requirement to return a fresh object;
4871
// mutating in place could corrupt the caller's tool definition.

packages/openai-base/src/utils/schema-converter.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ const SUPPORTED_STRING_FORMATS = new Set([
2727
* a bare string, so it is preserved and recursed into; only the `format`
2828
* *keyword* (whose value is a string) is subject to removal.
2929
*/
30-
function stripUnsupportedFormats(node: any): any {
30+
export function stripUnsupportedFormats(node: any): any {
3131
if (Array.isArray(node)) return node.map(stripUnsupportedFormats)
3232
if (node === null || typeof node !== 'object') return node
3333

@@ -64,6 +64,55 @@ export function makeStructuredOutputCompatible(
6464
return stripUnsupportedFormats(coerceStrictSchema(schema, originalRequired))
6565
}
6666

67+
/**
68+
* JSON-Schema keywords outside OpenAI's strict Structured Outputs subset. A
69+
* schema using any of these can't be coerced into a strict-valid shape, and
70+
* sending it with `strict: true` makes the API reject the ENTIRE request
71+
* (e.g. `400 Invalid schema ... 'additionalProperties' is required to be ...`).
72+
* Tools with such schemas are emitted with `strict: false` instead (see the
73+
* tool converters) so they remain callable. MCP servers (e.g. Notion) routinely
74+
* emit these.
75+
*
76+
* - `oneOf` / `allOf` / `not` — combinator keywords strict mode rejects
77+
* - `$ref` / `$defs` / `definitions` — references and definition pools whose
78+
* object subschemas escape the `additionalProperties: false` normalization
79+
* strict mode requires
80+
*/
81+
const STRICT_UNSUPPORTED_KEYWORDS: ReadonlyArray<string> = [
82+
'oneOf',
83+
'allOf',
84+
'not',
85+
'$ref',
86+
'$defs',
87+
'definitions',
88+
]
89+
90+
/**
91+
* Returns `false` when `schema` (anywhere in the tree) uses a JSON-Schema
92+
* keyword outside OpenAI's strict Structured Outputs subset — i.e. it cannot be
93+
* made strict-compatible and must be sent with `strict: false`.
94+
*
95+
* Conservative by design: keywords are matched as object keys, so a property
96+
* literally named e.g. `oneOf` also trips it. That only costs that one tool its
97+
* strict mode, which is strictly safer than a false "compatible" verdict that
98+
* 400s the whole request.
99+
*/
100+
export function isStrictModeCompatible(schema: unknown): boolean {
101+
return !containsStrictUnsupportedKeyword(schema)
102+
}
103+
104+
function containsStrictUnsupportedKeyword(node: unknown): boolean {
105+
if (Array.isArray(node)) {
106+
return node.some(containsStrictUnsupportedKeyword)
107+
}
108+
if (node === null || typeof node !== 'object') return false
109+
for (const [key, value] of Object.entries(node)) {
110+
if (STRICT_UNSUPPORTED_KEYWORDS.includes(key)) return true
111+
if (containsStrictUnsupportedKeyword(value)) return true
112+
}
113+
return false
114+
}
115+
67116
/**
68117
* Strict-mode structural rewrite (required widening, nullability,
69118
* additionalProperties). Kept private so the public entry point can apply the

packages/openai-base/tests/schema-converter.test.ts

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { describe, expect, it } from 'vitest'
2-
import { makeStructuredOutputCompatible } from '../src/utils/schema-converter'
2+
import {
3+
isStrictModeCompatible,
4+
makeStructuredOutputCompatible,
5+
} from '../src/utils/schema-converter'
36

47
describe('makeStructuredOutputCompatible', () => {
58
it('should add additionalProperties: false to object schemas', () => {
@@ -334,3 +337,82 @@ describe('makeStructuredOutputCompatible', () => {
334337
expect(schema.properties.data.format).toBe('uri')
335338
})
336339
})
340+
341+
describe('isStrictModeCompatible', () => {
342+
it('returns true for a plain object schema in the strict subset', () => {
343+
expect(
344+
isStrictModeCompatible({
345+
type: 'object',
346+
properties: { name: { type: 'string' } },
347+
required: ['name'],
348+
}),
349+
).toBe(true)
350+
})
351+
352+
it('returns true for nested objects, arrays, and anyOf (all strict-supported)', () => {
353+
expect(
354+
isStrictModeCompatible({
355+
type: 'object',
356+
properties: {
357+
items: {
358+
type: 'array',
359+
items: {
360+
type: 'object',
361+
properties: {
362+
v: { anyOf: [{ type: 'string' }, { type: 'number' }] },
363+
},
364+
},
365+
},
366+
},
367+
}),
368+
).toBe(true)
369+
})
370+
371+
it.each(['oneOf', 'allOf', 'not'])(
372+
'returns false when a combinator keyword (%s) appears anywhere',
373+
(keyword) => {
374+
expect(
375+
isStrictModeCompatible({
376+
type: 'object',
377+
properties: {
378+
value: { [keyword]: [{ type: 'string' }] },
379+
},
380+
}),
381+
).toBe(false)
382+
},
383+
)
384+
385+
it('returns false for schemas using $ref / $defs (references escape strict normalization)', () => {
386+
expect(
387+
isStrictModeCompatible({
388+
type: 'object',
389+
properties: { user: { $ref: '#/$defs/user' } },
390+
$defs: {
391+
user: { type: 'object', properties: { id: { type: 'string' } } },
392+
},
393+
}),
394+
).toBe(false)
395+
})
396+
397+
it('detects unsupported keywords nested deep in the tree', () => {
398+
expect(
399+
isStrictModeCompatible({
400+
type: 'object',
401+
properties: {
402+
a: {
403+
type: 'object',
404+
properties: {
405+
b: { type: 'array', items: { oneOf: [{ type: 'string' }] } },
406+
},
407+
},
408+
},
409+
}),
410+
).toBe(false)
411+
})
412+
413+
it('handles non-object input without throwing', () => {
414+
expect(isStrictModeCompatible(undefined)).toBe(true)
415+
expect(isStrictModeCompatible(null)).toBe(true)
416+
expect(isStrictModeCompatible('x')).toBe(true)
417+
})
418+
})
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { convertFunctionToolToResponsesFormat } from '../src/adapters/responses-tool-converter'
3+
import { convertFunctionToolToChatCompletionsFormat } from '../src/adapters/chat-completions-tool-converter'
4+
import type { Tool } from '@tanstack/ai'
5+
6+
/** A schema fully inside OpenAI's strict Structured Outputs subset. */
7+
const strictSafeTool: Tool = {
8+
name: 'get_user',
9+
description: 'Get a user',
10+
inputSchema: {
11+
type: 'object',
12+
properties: { query: { type: 'string' } },
13+
required: ['query'],
14+
},
15+
}
16+
17+
/**
18+
* Mirrors a Notion-style MCP schema: uses `$defs` + `oneOf` (outside the strict
19+
* subset) plus an unsupported `format`. With `strict: true` OpenAI 400s the
20+
* whole request, so the converter must fall back to `strict: false`.
21+
*/
22+
const gnarlyTool: Tool = {
23+
name: 'API-get-user',
24+
description: 'Notion get user',
25+
inputSchema: {
26+
type: 'object',
27+
properties: {
28+
user_id: { type: 'string', format: 'uuid' },
29+
site: { type: 'string', format: 'uri' },
30+
},
31+
required: ['user_id'],
32+
$defs: {
33+
parent: {
34+
oneOf: [
35+
{ type: 'object', properties: { page_id: { type: 'string' } } },
36+
],
37+
},
38+
},
39+
} as unknown as Tool['inputSchema'],
40+
}
41+
42+
describe('responses tool converter — strict fallback', () => {
43+
it('uses strict:true for strict-subset schemas', () => {
44+
const out = convertFunctionToolToResponsesFormat(strictSafeTool)
45+
expect(out.strict).toBe(true)
46+
expect(
47+
(out.parameters as Record<string, unknown>).additionalProperties,
48+
).toBe(false)
49+
})
50+
51+
it('falls back to strict:false for schemas with unsupported keywords', () => {
52+
const out = convertFunctionToolToResponsesFormat(gnarlyTool)
53+
expect(out.strict).toBe(false)
54+
// Schema is preserved (not corrupted) so the tool stays callable...
55+
const params = out.parameters as any
56+
expect(params.$defs.parent.oneOf).toBeDefined()
57+
// ...but unsupported `format` keywords are still stripped.
58+
expect(params.properties.site.format).toBeUndefined()
59+
expect(params.properties.user_id.format).toBe('uuid')
60+
})
61+
})
62+
63+
describe('chat-completions tool converter — strict fallback', () => {
64+
it('uses strict:true for strict-subset schemas', () => {
65+
const out = convertFunctionToolToChatCompletionsFormat(strictSafeTool)
66+
expect(out.function.strict).toBe(true)
67+
})
68+
69+
it('falls back to strict:false for schemas with unsupported keywords', () => {
70+
const out = convertFunctionToolToChatCompletionsFormat(gnarlyTool)
71+
expect(out.function.strict).toBe(false)
72+
const params = out.function.parameters as any
73+
expect(params.$defs.parent.oneOf).toBeDefined()
74+
expect(params.properties.site.format).toBeUndefined()
75+
})
76+
})

0 commit comments

Comments
 (0)