Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-maps-remain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/openai-base': patch
---

Fall back to non-strict function tools for free-form map schemas so OpenAI requests preserve dynamic keys and avoid strict-schema validation errors.
44 changes: 43 additions & 1 deletion packages/openai-base/src/utils/schema-converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ const TYPE_INDICATOR_KEYWORDS: ReadonlyArray<string> = [
* 2. It contains a *typeless* schema node — a property/items/anyOf entry with
* no `type` (nor `enum`/`const`/combinator), e.g. the `{}` that `z.any()`
* produces. Strict mode rejects typeless schemas.
* 3. It contains an open object schema. OpenAI strict mode requires objects to
* set `additionalProperties: false`, which would change the semantics of a
* free-form map rather than merely normalizing it.
*
* Conservative by design: for (1) keywords are matched as object keys, so a
* property literally named e.g. `oneOf` also trips it. That only costs that one
Expand All @@ -123,10 +126,49 @@ const TYPE_INDICATOR_KEYWORDS: ReadonlyArray<string> = [
*/
export function isStrictModeCompatible(schema: unknown): boolean {
return (
!containsStrictUnsupportedKeyword(schema) && !containsTypelessSchema(schema)
!containsStrictUnsupportedKeyword(schema) &&
!containsTypelessSchema(schema) &&
!containsOpenObject(schema)
)
}

/**
* Reports object schemas that cannot be closed without changing their input
* semantics. Objects with `properties` and no explicit
* `additionalProperties` are safe because `coerceStrictSchema` closes them.
*/
function containsOpenObject(node: unknown): boolean {
if (Array.isArray(node)) {
return node.some(containsOpenObject)
}
if (node === null || typeof node !== 'object') return false

const schema = node as Record<string, unknown>
const type = schema['type']
const isObjectSchema =
type === 'object' || (Array.isArray(type) && type.includes('object'))

if (isObjectSchema) {
if (
'additionalProperties' in schema &&
schema['additionalProperties'] !== false
) {
return true
}

const properties = schema['properties']
const hasProperties =
properties !== null &&
typeof properties === 'object' &&
!Array.isArray(properties)
if (!hasProperties && schema['additionalProperties'] !== false) {
return true
}
}

return Object.values(schema).some(containsOpenObject)
}

function containsStrictUnsupportedKeyword(node: unknown): boolean {
if (Array.isArray(node)) {
return node.some(containsStrictUnsupportedKeyword)
Expand Down
36 changes: 36 additions & 0 deletions packages/openai-base/tests/schema-converter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -489,4 +489,40 @@ describe('isStrictModeCompatible', () => {
isStrictModeCompatible({ type: 'object', properties: {}, required: [] }),
).toBe(true)
})

it.each([true, {}, { type: 'string' }])(
'returns false for a free-form map with additionalProperties: %j',
(additionalProperties) => {
expect(
isStrictModeCompatible({
type: 'object',
additionalProperties,
}),
).toBe(false)
},
)

it('returns false for a nested hybrid object with dynamic keys', () => {
expect(
isStrictModeCompatible({
type: 'object',
properties: {
labels: {
type: 'object',
properties: { fixed: { type: 'string' } },
additionalProperties: { type: 'string' },
},
},
}),
).toBe(false)
})

it('allows an explicitly closed empty object', () => {
expect(
isStrictModeCompatible({
type: 'object',
additionalProperties: false,
}),
).toBe(true)
})
})
33 changes: 33 additions & 0 deletions packages/openai-base/tests/tool-converter-strict-fallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,21 @@ const gnarlyTool: Tool = {
} as unknown as Tool['inputSchema'],
}

const freeFormMapTool: Tool = {
name: 'store_labels',
description: 'Store arbitrary labels',
inputSchema: {
type: 'object',
properties: {
labels: {
type: 'object',
additionalProperties: { type: 'string' },
},
},
required: ['labels'],
},
}

describe('responses tool converter — strict fallback', () => {
it('uses strict:true for strict-subset schemas', () => {
const out = convertFunctionToolToResponsesFormat(strictSafeTool)
Expand All @@ -59,6 +74,14 @@ describe('responses tool converter — strict fallback', () => {
expect(params.properties.site.format).toBeUndefined()
expect(params.properties.user_id.format).toBe('uuid')
})

it('falls back to strict:false without closing free-form maps', () => {
const out = convertFunctionToolToResponsesFormat(freeFormMapTool)
expect(out.strict).toBe(false)
expect(
(out.parameters as any).properties.labels.additionalProperties,
).toEqual({ type: 'string' })
})
})

describe('chat-completions tool converter — strict fallback', () => {
Expand All @@ -74,6 +97,11 @@ describe('chat-completions tool converter — strict fallback', () => {
expect(params.$defs.parent.oneOf).toBeDefined()
expect(params.properties.site.format).toBeUndefined()
})

it('falls back to strict:false for free-form maps', () => {
const out = convertFunctionToolToChatCompletionsFormat(freeFormMapTool)
expect(out.function.strict).toBe(false)
})
})

// This is the converter the provider tool-dispatcher (`convertToolsToProviderFormat`)
Expand All @@ -98,4 +126,9 @@ describe('function-tool adapter converter — strict fallback', () => {
expect(params.properties.site.format).toBeUndefined()
expect(params.properties.user_id.format).toBe('uuid')
})

it('falls back to strict:false for free-form maps', () => {
const out = convertFunctionToolToAdapterFormat(freeFormMapTool)
expect(out.strict).toBe(false)
})
})
17 changes: 17 additions & 0 deletions testing/e2e/src/routes/api.openai-shell-skills-wire.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,26 @@
import { createFileRoute } from '@tanstack/react-router'
import { chat, createChatOptions } from '@tanstack/ai'
import type { Tool } from '@tanstack/ai'
import { createOpenaiChat } from '@tanstack/ai-openai'
import { shellTool } from '@tanstack/ai-openai/tools'

const DUMMY_KEY = 'sk-e2e-test-dummy-key'

const freeFormMapTool = {
name: 'store_labels',
description: 'Store arbitrary labels',
inputSchema: {
type: 'object',
properties: {
labels: {
type: 'object',
additionalProperties: { type: 'string' },
},
},
required: ['labels'],
},
} satisfies Tool

/**
* Drives the OpenAI chat adapter (Responses API) with a `shellTool` that
* carries a `container_auto` environment + skills reference. A custom `fetch`
Expand Down Expand Up @@ -184,6 +200,7 @@ export const Route = createFileRoute('/api/openai-shell-skills-wire')({
],
},
}),
freeFormMapTool,
],
})) {
// Drain the stream.
Expand Down
35 changes: 35 additions & 0 deletions testing/e2e/tests/openai-shell-skills-wire.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,39 @@ test.describe('openai — shell tool skills wire format', () => {
},
})
})

test('free-form map tools use the non-strict wire format', async ({
request,
}) => {
const res = await request.post('/api/openai-shell-skills-wire')
expect(res.ok()).toBe(true)
const { ok, error, capturedRequest } = (await res.json()) as {
ok: boolean
error?: string
capturedRequest: {
body: Record<string, unknown> | null
} | null
}

if (!ok) {
throw new Error(`Route failed: ${error}`)
}

const body = capturedRequest?.body
const tools = body?.['tools'] as Array<Record<string, unknown>> | undefined
const mapTool = tools?.find((tool) => tool['name'] === 'store_labels')

expect(mapTool).toMatchObject({
type: 'function',
name: 'store_labels',
strict: false,
parameters: {
properties: {
labels: {
additionalProperties: { type: 'string' },
},
},
},
})
})
})
Loading