fix: OpenAI adapter tool calling compatibility

Two fixes for OpenAI-compatible provider compatibility:

1. Sanitize JSON Schema `const` → `enum` in tool parameters.
   Many OpenAI-compatible endpoints (Ollama, DeepSeek, vLLM, etc.)
   do not support the `const` keyword in JSON Schema. Recursively
   convert `const: value` to `enum: [value]` which is semantically
   equivalent.

2. Force stop_reason to `tool_use` when tool_calls are present.
   Some backends incorrectly return finish_reason "stop" even when
   the response contains tool_calls. Without this fix, the query
   loop treats the response as a normal end_turn and never executes
   the requested tools.
This commit is contained in:
uk0
2026-04-06 13:31:28 +08:00
parent 258cc720f4
commit e88dcb2f9e
4 changed files with 165 additions and 3 deletions

View File

@@ -59,6 +59,88 @@ describe('anthropicToolsToOpenAI', () => {
test('handles empty tools array', () => {
expect(anthropicToolsToOpenAI([])).toEqual([])
})
test('sanitizes const to enum in tool schema', () => {
const tools = [
{
type: 'custom',
name: 'test',
description: 'test tool',
input_schema: {
type: 'object',
properties: {
mode: { const: 'read' },
name: { type: 'string' },
},
},
},
]
const result = anthropicToolsToOpenAI(tools as any)
const props = result[0].function.parameters as any
expect(props.properties.mode).toEqual({ enum: ['read'] })
expect(props.properties.mode.const).toBeUndefined()
expect(props.properties.name).toEqual({ type: 'string' })
})
test('sanitizes const in deeply nested schemas', () => {
const tools = [
{
type: 'custom',
name: 'deep',
description: 'nested const',
input_schema: {
type: 'object',
properties: {
outer: {
type: 'object',
properties: {
inner: { const: 'fixed' },
},
},
},
definitions: {
MyType: {
type: 'object',
properties: {
field: { const: 42 },
},
},
},
},
},
]
const result = anthropicToolsToOpenAI(tools as any)
const params = result[0].function.parameters as any
expect(params.properties.outer.properties.inner).toEqual({ enum: ['fixed'] })
expect(params.definitions.MyType.properties.field).toEqual({ enum: [42] })
})
test('sanitizes const in anyOf/oneOf/allOf', () => {
const tools = [
{
type: 'custom',
name: 'union',
description: 'union test',
input_schema: {
type: 'object',
properties: {
val: {
anyOf: [
{ const: 'a' },
{ const: 'b' },
{ type: 'string' },
],
},
},
},
},
]
const result = anthropicToolsToOpenAI(tools as any)
const anyOf = (result[0].function.parameters as any).properties.val.anyOf
expect(anyOf[0]).toEqual({ enum: ['a'] })
expect(anyOf[1]).toEqual({ enum: ['b'] })
expect(anyOf[2]).toEqual({ type: 'string' })
})
})
describe('anthropicToolChoiceToOpenAI', () => {

View File

@@ -165,6 +165,28 @@ describe('adaptOpenAIStreamToAnthropic', () => {
expect(msgDelta.delta.stop_reason).toBe('end_turn')
})
test('forces tool_use stop_reason when tool_calls present but finish_reason is stop', async () => {
// Some backends (e.g., certain OpenAI-compatible endpoints) incorrectly
// return finish_reason "stop" when they actually made tool calls.
const events = await collectEvents([
makeChunk({
choices: [{
index: 0,
delta: {
tool_calls: [{ index: 0, id: 'call_1', function: { name: 'bash', arguments: '{"cmd":"ls"}' } }],
},
finish_reason: null,
}],
}),
makeChunk({
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
}),
])
const msgDelta = events.find(e => e.type === 'message_delta') as any
expect(msgDelta.delta.stop_reason).toBe('tool_use')
})
test('maps finish_reason tool_calls to tool_use', async () => {
const events = await collectEvents([
makeChunk({

View File

@@ -29,12 +29,66 @@ export function anthropicToolsToOpenAI(
function: {
name,
description,
parameters: inputSchema || { type: 'object', properties: {} },
parameters: sanitizeJsonSchema(inputSchema || { type: 'object', properties: {} }),
},
} satisfies ChatCompletionTool
})
}
/**
* Recursively sanitize a JSON Schema for OpenAI-compatible providers.
*
* Many OpenAI-compatible endpoints (Ollama, DeepSeek, vLLM, etc.) do not
* support the `const` keyword in JSON Schema. Convert it to `enum` with a
* single-element array, which is semantically equivalent.
*/
function sanitizeJsonSchema(schema: Record<string, unknown>): Record<string, unknown> {
if (!schema || typeof schema !== 'object') return schema
const result = { ...schema }
// Convert `const` → `enum: [value]`
if ('const' in result) {
result.enum = [result.const]
delete result.const
}
// Recursively process nested schemas
const objectKeys = ['properties', 'definitions', '$defs', 'patternProperties'] as const
for (const key of objectKeys) {
const nested = result[key]
if (nested && typeof nested === 'object') {
const sanitized: Record<string, unknown> = {}
for (const [k, v] of Object.entries(nested as Record<string, unknown>)) {
sanitized[k] = v && typeof v === 'object' ? sanitizeJsonSchema(v as Record<string, unknown>) : v
}
result[key] = sanitized
}
}
// Recursively process single-schema keys
const singleKeys = ['items', 'additionalProperties', 'not', 'if', 'then', 'else', 'contains', 'propertyNames'] as const
for (const key of singleKeys) {
const nested = result[key]
if (nested && typeof nested === 'object' && !Array.isArray(nested)) {
result[key] = sanitizeJsonSchema(nested as Record<string, unknown>)
}
}
// Recursively process array-of-schemas keys
const arrayKeys = ['anyOf', 'oneOf', 'allOf'] as const
for (const key of arrayKeys) {
const nested = result[key]
if (Array.isArray(nested)) {
result[key] = nested.map(item =>
item && typeof item === 'object' ? sanitizeJsonSchema(item as Record<string, unknown>) : item
)
}
}
return result
}
/**
* Map Anthropic tool_choice to OpenAI tool_choice format.
*

View File

@@ -257,8 +257,12 @@ export async function* adaptOpenAIStreamToAnthropic(
}
}
// Map finish_reason to Anthropic stop_reason
const stopReason = mapFinishReason(choice.finish_reason)
// Map finish_reason to Anthropic stop_reason.
// Some backends return "stop" even when tool_calls are present —
// force "tool_use" when we saw any tool blocks to ensure the query
// loop actually executes the tools.
const hasToolCalls = toolBlocks.size > 0
const stopReason = hasToolCalls ? 'tool_use' : mapFinishReason(choice.finish_reason)
yield {
type: 'message_delta',