diff --git a/.changeset/calm-maps-remain.md b/.changeset/calm-maps-remain.md new file mode 100644 index 000000000..175d7d7ba --- /dev/null +++ b/.changeset/calm-maps-remain.md @@ -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. diff --git a/packages/openai-base/src/utils/schema-converter.ts b/packages/openai-base/src/utils/schema-converter.ts index 0541788a5..540308be1 100644 --- a/packages/openai-base/src/utils/schema-converter.ts +++ b/packages/openai-base/src/utils/schema-converter.ts @@ -115,6 +115,9 @@ const TYPE_INDICATOR_KEYWORDS: ReadonlyArray = [ * 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 @@ -123,10 +126,49 @@ const TYPE_INDICATOR_KEYWORDS: ReadonlyArray = [ */ 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 + 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) diff --git a/packages/openai-base/tests/schema-converter.test.ts b/packages/openai-base/tests/schema-converter.test.ts index b7cf600f1..8915a8560 100644 --- a/packages/openai-base/tests/schema-converter.test.ts +++ b/packages/openai-base/tests/schema-converter.test.ts @@ -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) + }) }) diff --git a/packages/openai-base/tests/tool-converter-strict-fallback.test.ts b/packages/openai-base/tests/tool-converter-strict-fallback.test.ts index 49dafb491..c2dc26ee1 100644 --- a/packages/openai-base/tests/tool-converter-strict-fallback.test.ts +++ b/packages/openai-base/tests/tool-converter-strict-fallback.test.ts @@ -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) @@ -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', () => { @@ -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`) @@ -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) + }) }) diff --git a/testing/e2e/src/routes/api.openai-shell-skills-wire.ts b/testing/e2e/src/routes/api.openai-shell-skills-wire.ts index ce5dcdb23..631b39fe9 100644 --- a/testing/e2e/src/routes/api.openai-shell-skills-wire.ts +++ b/testing/e2e/src/routes/api.openai-shell-skills-wire.ts @@ -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` @@ -184,6 +200,7 @@ export const Route = createFileRoute('/api/openai-shell-skills-wire')({ ], }, }), + freeFormMapTool, ], })) { // Drain the stream. diff --git a/testing/e2e/tests/openai-shell-skills-wire.spec.ts b/testing/e2e/tests/openai-shell-skills-wire.spec.ts index 099c4be79..ad4d4e8c2 100644 --- a/testing/e2e/tests/openai-shell-skills-wire.spec.ts +++ b/testing/e2e/tests/openai-shell-skills-wire.spec.ts @@ -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 | null + } | null + } + + if (!ok) { + throw new Error(`Route failed: ${error}`) + } + + const body = capturedRequest?.body + const tools = body?.['tools'] as Array> | 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' }, + }, + }, + }, + }) + }) })