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-tools-return.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/openai-base': patch
---

Undo strict-mode null widening before validating and executing OpenAI-compatible tool calls.
13 changes: 10 additions & 3 deletions packages/openai-base/src/adapters/chat-completions-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
import { generateId } from '@tanstack/ai-utils'
import { extractRequestOptions } from '../utils/request-options'
import { makeStructuredOutputCompatible } from '../utils/schema-converter'
import { createToolInputNormalizer } from '../utils/tool-input-normalizer'
import { buildChatCompletionsUsage } from '../usage'
import { convertToolsToChatCompletionsFormat } from './chat-completions-tool-converter'
import type OpenAI from 'openai'
Expand Down Expand Up @@ -620,6 +621,7 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter<
hasEmittedRunStarted: boolean
},
): AsyncIterable<StreamChunk> {
const normalizeToolInput = createToolInputNormalizer(options.tools)
let accumulatedContent = ''
let hasEmittedTextMessageStart = false
let lastModel: string | undefined
Expand Down Expand Up @@ -888,8 +890,10 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter<
if (toolCall.arguments) {
try {
const parsed: unknown = JSON.parse(toolCall.arguments)
parsedInput =
parsed && typeof parsed === 'object' ? parsed : {}
parsedInput = normalizeToolInput(
toolCall.name,
parsed && typeof parsed === 'object' ? parsed : {},
)
} catch (parseError) {
options.logger.errors(
`${this.name}.processStreamChunks tool-args JSON parse failed`,
Expand Down Expand Up @@ -960,7 +964,10 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter<
if (toolCall.arguments) {
try {
const parsed: unknown = JSON.parse(toolCall.arguments)
parsedInput = parsed && typeof parsed === 'object' ? parsed : {}
parsedInput = normalizeToolInput(
toolCall.name,
parsed && typeof parsed === 'object' ? parsed : {},
)
} catch (parseError) {
// Mirror the finish_reason path's logger call — a truncated
// stream emitting malformed tool-call JSON would otherwise
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest'
import { convertFunctionToolToChatCompletionsFormat } from './chat-completions-tool-converter'
import type { Tool } from '@tanstack/ai'

describe('chat-completions tool converter', () => {
it('falls back from strict mode when an anyOf variant needs null widening', () => {
const out = convertFunctionToolToChatCompletionsFormat(
anyOfOptionalVariantTool,
)

expect(out.function.strict).toBe(false)
})

it('falls back from strict mode for boolean schema nodes', () => {
const out = convertFunctionToolToChatCompletionsFormat(booleanSchemaTool)

expect(out.function.strict).toBe(false)
expect(out.function.parameters).toEqual(booleanSchemaTool.inputSchema)
})
})

const booleanSchemaInput = {
type: 'object',
properties: {},
required: [],
}
Reflect.set(booleanSchemaInput.properties, 'acceptAnything', true)

const booleanSchemaTool = {
name: 'accept_anything',
description: 'Accept any value',
inputSchema: booleanSchemaInput,
} satisfies Tool

const anyOfOptionalVariantTool: Tool = {
name: 'store_variant',
description: 'Store a union variant',
inputSchema: {
type: 'object',
properties: {
value: {
anyOf: [
{
type: 'object',
properties: {
kind: { const: 'optional' },
note: { type: 'string' },
},
required: ['kind'],
},
{
type: 'object',
properties: {
kind: { const: 'nullable' },
note: { type: ['string', 'null'] },
},
required: ['kind', 'note'],
},
],
},
},
required: ['value'],
},
}
19 changes: 14 additions & 5 deletions packages/openai-base/src/adapters/responses-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
import { generateId } from '@tanstack/ai-utils'
import { extractRequestOptions } from '../utils/request-options'
import { makeStructuredOutputCompatible } from '../utils/schema-converter'
import { createToolInputNormalizer } from '../utils/tool-input-normalizer'
import { buildResponsesUsage } from '../usage'
import { convertToolsToResponsesFormat } from './responses-tool-converter'
import type OpenAI from 'openai'
Expand Down Expand Up @@ -777,6 +778,7 @@ export abstract class OpenAIBaseResponsesTextAdapter<
hasEmittedRunStarted: boolean
},
): AsyncIterable<StreamChunk> {
const normalizeToolInput = createToolInputNormalizer(options.tools)
let accumulatedContent = ''
let accumulatedReasoning = ''

Expand Down Expand Up @@ -1287,7 +1289,10 @@ export abstract class OpenAIBaseResponsesTextAdapter<
if (chunk.arguments) {
try {
const parsed = JSON.parse(chunk.arguments)
parsedInput = parsed && typeof parsed === 'object' ? parsed : {}
parsedInput = normalizeToolInput(
name,
parsed && typeof parsed === 'object' ? parsed : {},
)
} catch (parseError) {
options.logger.errors(
`${this.name}.processStreamChunks tool-args JSON parse failed`,
Expand Down Expand Up @@ -1361,8 +1366,10 @@ export abstract class OpenAIBaseResponsesTextAdapter<
if (rawArgs) {
try {
const parsed = JSON.parse(rawArgs)
parsedInput =
parsed && typeof parsed === 'object' ? parsed : {}
parsedInput = normalizeToolInput(
name,
parsed && typeof parsed === 'object' ? parsed : {},
)
} catch (parseError) {
options.logger.errors(
`${this.name}.processStreamChunks tool-args JSON parse failed (output_item.done backfill)`,
Expand Down Expand Up @@ -1438,8 +1445,10 @@ export abstract class OpenAIBaseResponsesTextAdapter<
if (rawArgs) {
try {
const parsed = JSON.parse(rawArgs)
parsedInput =
parsed && typeof parsed === 'object' ? parsed : {}
parsedInput = normalizeToolInput(
name,
parsed && typeof parsed === 'object' ? parsed : {},
)
} catch (parseError) {
options.logger.errors(
`${this.name}.processStreamChunks tool-args JSON parse failed (response.completed backfill)`,
Expand Down
72 changes: 72 additions & 0 deletions packages/openai-base/src/adapters/responses-tool-converter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest'
import { convertFunctionToolToResponsesFormat } from './responses-tool-converter'
import type { Tool } from '@tanstack/ai'

describe('responses tool converter', () => {
it('falls back from strict mode when an anyOf variant needs null widening', () => {
const out = convertFunctionToolToResponsesFormat(anyOfOptionalVariantTool)

expect(out.strict).toBe(false)
expect(out.parameters).toMatchObject({
properties: {
value: {
anyOf: [
{ required: ['kind'] },
{ required: ['kind', 'note'] },
],
},
},
})
})

it('falls back from strict mode for boolean schema nodes', () => {
const out = convertFunctionToolToResponsesFormat(booleanSchemaTool)

expect(out.strict).toBe(false)
expect(out.parameters).toEqual(booleanSchemaTool.inputSchema)
})
})

const booleanSchemaInput = {
type: 'object',
properties: {},
required: [],
}
Reflect.set(booleanSchemaInput.properties, 'acceptAnything', true)

const booleanSchemaTool = {
name: 'accept_anything',
description: 'Accept any value',
inputSchema: booleanSchemaInput,
} satisfies Tool

const anyOfOptionalVariantTool: Tool = {
name: 'store_variant',
description: 'Store a union variant',
inputSchema: {
type: 'object',
properties: {
value: {
anyOf: [
{
type: 'object',
properties: {
kind: { const: 'optional' },
note: { type: 'string' },
},
required: ['kind'],
},
{
type: 'object',
properties: {
kind: { const: 'nullable' },
note: { type: ['string', 'null'] },
},
required: ['kind', 'note'],
},
],
},
},
required: ['value'],
},
}
62 changes: 62 additions & 0 deletions packages/openai-base/src/tools/function-tool.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import { convertFunctionToolToAdapterFormat } from './function-tool'
import type { Tool } from '@tanstack/ai'

describe('function-tool adapter converter', () => {
it('falls back from strict mode when an anyOf variant needs null widening', () => {
const out = convertFunctionToolToAdapterFormat(anyOfOptionalVariantTool)

expect(out.strict).toBe(false)
})

it('falls back from strict mode for boolean schema nodes', () => {
const out = convertFunctionToolToAdapterFormat(booleanSchemaTool)

expect(out.strict).toBe(false)
expect(out.parameters).toEqual(booleanSchemaTool.inputSchema)
})
})

const booleanSchemaInput = {
type: 'object',
properties: {},
required: [],
}
Reflect.set(booleanSchemaInput.properties, 'acceptAnything', true)

const booleanSchemaTool = {
name: 'accept_anything',
description: 'Accept any value',
inputSchema: booleanSchemaInput,
} satisfies Tool

const anyOfOptionalVariantTool: Tool = {
name: 'store_variant',
description: 'Store a union variant',
inputSchema: {
type: 'object',
properties: {
value: {
anyOf: [
{
type: 'object',
properties: {
kind: { const: 'optional' },
note: { type: 'string' },
},
required: ['kind'],
},
{
type: 'object',
properties: {
kind: { const: 'nullable' },
note: { type: ['string', 'null'] },
},
required: ['kind', 'note'],
},
],
},
},
required: ['value'],
},
}
Loading