fix: preserve only originally-required properties in strict tool schemas (#471)
Fixes #430. In normalizeSchemaForOpenAI(), the strict branch was adding every property key to required[], including optional ones. This caused providers like Groq, Azure OpenAI, and others to reject valid tool calls with a 400 / tool_use_failed error because the model correctly omits optional arguments but the provider sees them as missing required fields. Root cause: the strict branch used `[...existingRequired, ...allKeys]` instead of `existingRequired.filter(k => k in normalizedProps)`. The Gemini branch already had the correct logic. Fix: align the strict branch with the Gemini branch — only keep properties that were already marked required in the original schema. The additionalProperties: false constraint is preserved as strict-mode providers still require it. Add regression test covering the Read tool schema (file_path required, offset/limit/pages optional).
This commit is contained in:
committed by
GitHub
parent
2caf2fd982
commit
ccaa193eec
@@ -1806,12 +1806,70 @@ test('sanitizes malformed MCP tool schemas before sending them to OpenAI', async
|
|||||||
| undefined
|
| undefined
|
||||||
|
|
||||||
expect(parameters?.additionalProperties).toBe(false)
|
expect(parameters?.additionalProperties).toBe(false)
|
||||||
expect(parameters?.required).toEqual(['priority'])
|
// No required[] in the original schema → none added (optional properties must not be forced required)
|
||||||
|
expect(parameters?.required).toEqual([])
|
||||||
expect(properties?.priority?.type).toBe('integer')
|
expect(properties?.priority?.type).toBe('integer')
|
||||||
expect(properties?.priority?.enum).toEqual([0, 1, 2, 3])
|
expect(properties?.priority?.enum).toEqual([0, 1, 2, 3])
|
||||||
expect(properties?.priority).not.toHaveProperty('default')
|
expect(properties?.priority).not.toHaveProperty('default')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('optional tool properties are not added to required[] — fixes Groq/Azure 400 tool_use_failed', async () => {
|
||||||
|
// Regression test for: all optional properties being sent as required in strict mode,
|
||||||
|
// causing providers like Groq to reject valid tool calls where the model omits optional args.
|
||||||
|
let requestBody: Record<string, unknown> | undefined
|
||||||
|
|
||||||
|
globalThis.fetch = (async (_input, init) => {
|
||||||
|
requestBody = JSON.parse(String(init?.body))
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
id: 'chatcmpl-4',
|
||||||
|
model: 'gpt-4o',
|
||||||
|
choices: [{ message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }],
|
||||||
|
usage: { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 },
|
||||||
|
}),
|
||||||
|
{ headers: { 'Content-Type': 'application/json' } },
|
||||||
|
)
|
||||||
|
}) as FetchType
|
||||||
|
|
||||||
|
const client = createOpenAIShimClient({}) as OpenAIShimClient
|
||||||
|
|
||||||
|
await client.beta.messages.create({
|
||||||
|
model: 'gpt-4o',
|
||||||
|
messages: [{ role: 'user', content: 'read a file' }],
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
name: 'Read',
|
||||||
|
description: 'Read a file',
|
||||||
|
input_schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
file_path: { type: 'string', description: 'Absolute path to file' },
|
||||||
|
offset: { type: 'number', description: 'Line to start from' },
|
||||||
|
limit: { type: 'number', description: 'Max lines to read' },
|
||||||
|
pages: { type: 'string', description: 'Page range for PDFs' },
|
||||||
|
},
|
||||||
|
required: ['file_path'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
max_tokens: 16,
|
||||||
|
stream: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const parameters = (
|
||||||
|
requestBody?.tools as Array<{ function?: { parameters?: Record<string, unknown> } }>
|
||||||
|
)?.[0]?.function?.parameters
|
||||||
|
|
||||||
|
expect(parameters?.required).toEqual(['file_path'])
|
||||||
|
|
||||||
|
const required = parameters?.required as string[] | undefined
|
||||||
|
expect(required).not.toContain('offset')
|
||||||
|
expect(required).not.toContain('limit')
|
||||||
|
expect(required).not.toContain('pages')
|
||||||
|
expect(parameters?.additionalProperties).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Issue #202 — consecutive role coalescing (Devstral, Mistral strict templates)
|
// Issue #202 — consecutive role coalescing (Devstral, Mistral strict templates)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1849,7 +1907,7 @@ test('coalesces consecutive user messages to avoid alternation errors (issue #20
|
|||||||
stream: false,
|
stream: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(sentMessages?.length).toBe(2) // system + 1 merged user
|
expect(sentMessages?.length).toBe(2)
|
||||||
expect(sentMessages?.[0]?.role).toBe('system')
|
expect(sentMessages?.[0]?.role).toBe('system')
|
||||||
expect(sentMessages?.[1]?.role).toBe('user')
|
expect(sentMessages?.[1]?.role).toBe('user')
|
||||||
const userContent = sentMessages?.[1]?.content as string
|
const userContent = sentMessages?.[1]?.content as string
|
||||||
@@ -1883,9 +1941,8 @@ test('coalesces consecutive assistant messages preserving tool_calls (issue #202
|
|||||||
stream: false,
|
stream: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
// system + user + merged assistant + tool
|
|
||||||
const assistantMsgs = sentMessages?.filter(m => m.role === 'assistant')
|
const assistantMsgs = sentMessages?.filter(m => m.role === 'assistant')
|
||||||
expect(assistantMsgs?.length).toBe(1) // two assistant turns merged into one
|
expect(assistantMsgs?.length).toBe(1)
|
||||||
expect(assistantMsgs?.[0]?.tool_calls?.length).toBeGreaterThan(0)
|
expect(assistantMsgs?.[0]?.tool_calls?.length).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1975,8 +2032,6 @@ test('non-streaming: empty string content does not fall through to reasoning_con
|
|||||||
stream: false,
|
stream: false,
|
||||||
})) as { content: Array<Record<string, unknown>> }
|
})) as { content: Array<Record<string, unknown>> }
|
||||||
|
|
||||||
// reasoning_content should be a thinking block, and also used as text
|
|
||||||
// since content is empty string (treated as absent)
|
|
||||||
expect(result.content).toEqual([
|
expect(result.content).toEqual([
|
||||||
{ type: 'thinking', thinking: 'Chain of thought here.' },
|
{ type: 'thinking', thinking: 'Chain of thought here.' },
|
||||||
{ type: 'text', text: 'Chain of thought here.' },
|
{ type: 'text', text: 'Chain of thought here.' },
|
||||||
@@ -2104,7 +2159,6 @@ test('streaming: thinking block closed before tool call', async () => {
|
|||||||
|
|
||||||
const types = events.map(e => e.type)
|
const types = events.map(e => e.type)
|
||||||
|
|
||||||
// Verify thinking block is started, then closed, then tool call starts
|
|
||||||
const thinkingStartIdx = types.indexOf('content_block_start')
|
const thinkingStartIdx = types.indexOf('content_block_start')
|
||||||
const firstStopIdx = types.indexOf('content_block_stop')
|
const firstStopIdx = types.indexOf('content_block_stop')
|
||||||
const toolStartIdx = types.indexOf(
|
const toolStartIdx = types.indexOf(
|
||||||
@@ -2116,7 +2170,6 @@ test('streaming: thinking block closed before tool call', async () => {
|
|||||||
expect(firstStopIdx).toBeGreaterThan(thinkingStartIdx)
|
expect(firstStopIdx).toBeGreaterThan(thinkingStartIdx)
|
||||||
expect(toolStartIdx).toBeGreaterThan(firstStopIdx)
|
expect(toolStartIdx).toBeGreaterThan(firstStopIdx)
|
||||||
|
|
||||||
// Verify thinking block start content
|
|
||||||
const thinkingStart = events[thinkingStartIdx] as {
|
const thinkingStart = events[thinkingStartIdx] as {
|
||||||
content_block?: Record<string, unknown>
|
content_block?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -421,11 +421,13 @@ function normalizeSchemaForOpenAI(
|
|||||||
record.properties = normalizedProps
|
record.properties = normalizedProps
|
||||||
|
|
||||||
if (strict) {
|
if (strict) {
|
||||||
// OpenAI strict mode requires every property to be listed in required[]
|
// Keep only the properties that were originally marked required in the schema.
|
||||||
const allKeys = Object.keys(normalizedProps)
|
// Adding every property to required[] (the previous behaviour) caused strict
|
||||||
record.required = Array.from(new Set([...existingRequired, ...allKeys]))
|
// OpenAI-compatible providers (Groq, Azure, etc.) to reject tool calls because
|
||||||
// OpenAI strict mode requires additionalProperties: false on all object
|
// the model correctly omits optional arguments — but the provider treats them
|
||||||
// schemas — override unconditionally to ensure nested objects comply.
|
// as missing required fields and returns a 400 / tool_use_failed error.
|
||||||
|
record.required = existingRequired.filter(k => k in normalizedProps)
|
||||||
|
// additionalProperties: false is still required by strict-mode providers.
|
||||||
record.additionalProperties = false
|
record.additionalProperties = false
|
||||||
} else {
|
} else {
|
||||||
// For Gemini: keep only existing required keys that are present in properties
|
// For Gemini: keep only existing required keys that are present in properties
|
||||||
|
|||||||
Reference in New Issue
Block a user