Skip to content
Draft
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
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
79 changes: 70 additions & 9 deletions packages/openai-base/src/utils/schema-converter.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { NullWideningMap } from '@tanstack/ai-utils'

/**
* String `format` values accepted by OpenAI's strict Structured Outputs subset.
* Any other format (e.g. "uri", "uri-reference", "regex") causes the API to
Expand Down Expand Up @@ -61,7 +63,31 @@ export function makeStructuredOutputCompatible(
schema: Record<string, any>,
originalRequired?: Array<string>,
): Record<string, any> {
return stripUnsupportedFormats(coerceStrictSchema(schema, originalRequired))
return makeStructuredOutputCompatibleWithMap(schema, originalRequired).schema
}

interface StructuredOutputCompatibility {
schema: Record<string, any>
nullWideningMap: NullWideningMap | undefined
}

/**
* Strict-schema conversion plus an exact map of the nullability introduced by
* that conversion. Consumers can pass provider output through
* `undoNullWidening` before validating it against the original schema.
*/
export function makeStructuredOutputCompatibleWithMap(
schema: Record<string, any>,
originalRequired?: Array<string>,
): StructuredOutputCompatibility {
const { schema: strictSchema, nullWideningMap } = coerceStrictSchema(
schema,
originalRequired,
)
return {
schema: stripUnsupportedFormats(strictSchema),
nullWideningMap,
}
}

/**
Expand Down Expand Up @@ -183,33 +209,49 @@ function containsTypelessSchema(node: unknown): boolean {
* additionalProperties). Kept private so the public entry point can apply the
* format-stripping pass exactly once over the fully-rewritten tree.
*/
function pruneMap(map: NullWideningMap): NullWideningMap | undefined {
return Object.keys(map).length > 0 ? map : undefined
}

function coerceStrictSchema(
schema: Record<string, any>,
originalRequired?: Array<string>,
): Record<string, any> {
): StructuredOutputCompatibility {
const result = { ...schema }
const nullWideningMap: NullWideningMap = {}
const required =
originalRequired ??
(Array.isArray(result['required']) ? result['required'] : [])

if (result.type === 'object' && result.properties) {
const properties = { ...result.properties }
const allPropertyNames = Object.keys(properties)
const propertyMaps: Record<string, NullWideningMap> = {}

for (const propName of allPropertyNames) {
let prop = properties[propName]
const wasOptional = !required.includes(propName)
let childMap: NullWideningMap | undefined
let widenedHere = false

// Step 1: Recurse into nested structures
if (prop.type === 'object' && prop.properties) {
prop = coerceStrictSchema(prop, prop.required || [])
const nested = coerceStrictSchema(prop, prop.required || [])
prop = nested.schema
childMap = nested.nullWideningMap
} else if (prop.type === 'array' && prop.items) {
const nested = coerceStrictSchema(prop.items, prop.items.required || [])
prop = {
...prop,
items: coerceStrictSchema(prop.items, prop.items.required || []),
items: nested.schema,
}
childMap = nested.nullWideningMap
? { items: nested.nullWideningMap }
: undefined
} else if (prop.anyOf) {
prop = coerceStrictSchema(prop, prop.required || [])
const nested = coerceStrictSchema(prop, prop.required || [])
prop = nested.schema
childMap = nested.nullWideningMap
} else if (prop.oneOf) {
throw new Error(
'oneOf is not supported in OpenAI structured output schemas. Check the supported outputs here: https://platform.openai.com/docs/guides/structured-outputs#supported-types',
Expand All @@ -222,29 +264,45 @@ function coerceStrictSchema(
// For anyOf, add a null variant if not already present
if (!prop.anyOf.some((v: any) => v.type === 'null')) {
prop = { ...prop, anyOf: [...prop.anyOf, { type: 'null' }] }
widenedHere = true
}
} else if (prop.type && !Array.isArray(prop.type)) {
prop = { ...prop, type: [prop.type, 'null'] }
widenedHere = true
} else if (Array.isArray(prop.type) && !prop.type.includes('null')) {
prop = { ...prop, type: [...prop.type, 'null'] }
widenedHere = true
}
}

properties[propName] = prop
if (childMap || widenedHere) {
propertyMaps[propName] = {
...(childMap ?? {}),
...(widenedHere ? { widened: true } : {}),
}
}
}

result.properties = properties
result.required = allPropertyNames
result.additionalProperties = false
if (Object.keys(propertyMaps).length > 0) {
nullWideningMap.properties = propertyMaps
}
}

if (result.type === 'array' && result.items) {
result.items = coerceStrictSchema(result.items, result.items.required || [])
const nested = coerceStrictSchema(result.items, result.items.required || [])
result.items = nested.schema
if (nested.nullWideningMap) {
nullWideningMap.items = nested.nullWideningMap
}
}

if (result.anyOf && Array.isArray(result.anyOf)) {
result.anyOf = result.anyOf.map((variant) =>
coerceStrictSchema(variant, variant.required || []),
result.anyOf = result.anyOf.map(
(variant) => coerceStrictSchema(variant, variant.required || []).schema,
)
}

Expand All @@ -254,5 +312,8 @@ function coerceStrictSchema(
)
}

return result
return {
schema: result,
nullWideningMap: pruneMap(nullWideningMap),
}
}
47 changes: 47 additions & 0 deletions packages/openai-base/src/utils/tool-input-normalizer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { undoNullWidening } from '@tanstack/ai-utils'
import {
isStrictModeCompatible,
makeStructuredOutputCompatibleWithMap,
} from './schema-converter'
import type { JSONSchema, Tool } from '@tanstack/ai'
import type { NullWideningMap } from '@tanstack/ai-utils'

type ToolInputNormalizer = (toolName: string, input: unknown) => unknown

/**
* Build the inverse transform for the strict tool schemas sent in one request.
* Non-strict tools are intentionally excluded because their schemas were not
* null-widened on the wire.
*/
export function createToolInputNormalizer(
tools: Array<Tool> | undefined,
): ToolInputNormalizer {
const maps = new Map<string, NullWideningMap>()
const seenNames = new Set<string>()
const ambiguousNames = new Set<string>()

for (const tool of tools ?? []) {
if (ambiguousNames.has(tool.name)) continue
if (seenNames.has(tool.name)) {
maps.delete(tool.name)
ambiguousNames.add(tool.name)
continue
}
seenNames.add(tool.name)

const inputSchema = (tool.inputSchema ?? {
type: 'object',
properties: {},
required: [],
}) as JSONSchema
if (!isStrictModeCompatible(inputSchema)) continue

const { nullWideningMap } = makeStructuredOutputCompatibleWithMap(
inputSchema,
inputSchema.required || [],
)
if (nullWideningMap) maps.set(tool.name, nullWideningMap)
}

return (toolName, input) => undoNullWidening(input, maps.get(toolName))
}
63 changes: 63 additions & 0 deletions packages/openai-base/tests/chat-completions-text.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,69 @@ describe('OpenAIBaseChatCompletionsTextAdapter', () => {
})

describe('tool call events', () => {
it('undoes strict null-widening before emitting the completed tool input', async () => {
const strictTool: Tool = {
name: 'ask_user',
description: 'Ask a question',
inputSchema: {
type: 'object',
properties: {
question: { type: 'string' },
options: { type: 'array', items: { type: 'string' } },
nullableNote: { type: ['string', 'null'] },
},
required: ['question', 'nullableNote'],
},
}
setupMockSdkClient([
{
id: 'chatcmpl-null-input',
model: 'test-model',
choices: [
{
delta: {
tool_calls: [
{
index: 0,
id: 'call-null-input',
type: 'function',
function: {
name: 'ask_user',
arguments:
'{"question":"Which one?","options":null,"nullableNote":null}',
},
},
],
},
finish_reason: null,
},
],
},
{
id: 'chatcmpl-null-input',
model: 'test-model',
choices: [{ delta: {}, finish_reason: 'tool_calls' }],
},
])
const adapter = new TestChatCompletionsAdapter(testConfig, 'test-model')
const chunks: Array<StreamChunk> = []

for await (const chunk of adapter.chatStream({
logger: testLogger,
model: 'test-model',
messages: [{ role: 'user', content: 'Ask me' }],
tools: [strictTool],
})) {
chunks.push(chunk)
}

expect(
chunks.find((chunk) => chunk.type === 'TOOL_CALL_END'),
).toMatchObject({
input: { question: 'Which one?', nullableNote: null },
})
})

it('emits TOOL_CALL_START -> TOOL_CALL_ARGS -> TOOL_CALL_END', async () => {
const streamChunks = [
{
Expand Down
Loading