mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-21 15:55:50 +00:00
Merge pull request #149 from uk0/fix/openai-tool-compat
fix: OpenAI adapter tool calling compatibility
This commit is contained in:
@@ -59,6 +59,88 @@ describe('anthropicToolsToOpenAI', () => {
|
|||||||
test('handles empty tools array', () => {
|
test('handles empty tools array', () => {
|
||||||
expect(anthropicToolsToOpenAI([])).toEqual([])
|
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', () => {
|
describe('anthropicToolChoiceToOpenAI', () => {
|
||||||
|
|||||||
@@ -165,6 +165,28 @@ describe('adaptOpenAIStreamToAnthropic', () => {
|
|||||||
expect(msgDelta.delta.stop_reason).toBe('end_turn')
|
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 () => {
|
test('maps finish_reason tool_calls to tool_use', async () => {
|
||||||
const events = await collectEvents([
|
const events = await collectEvents([
|
||||||
makeChunk({
|
makeChunk({
|
||||||
|
|||||||
@@ -29,12 +29,66 @@ export function anthropicToolsToOpenAI(
|
|||||||
function: {
|
function: {
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
parameters: inputSchema || { type: 'object', properties: {} },
|
parameters: sanitizeJsonSchema(inputSchema || { type: 'object', properties: {} }),
|
||||||
},
|
},
|
||||||
} satisfies ChatCompletionTool
|
} 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.
|
* Map Anthropic tool_choice to OpenAI tool_choice format.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -257,8 +257,12 @@ export async function* adaptOpenAIStreamToAnthropic(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map finish_reason to Anthropic stop_reason
|
// Map finish_reason to Anthropic stop_reason.
|
||||||
const stopReason = mapFinishReason(choice.finish_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 {
|
yield {
|
||||||
type: 'message_delta',
|
type: 'message_delta',
|
||||||
|
|||||||
Reference in New Issue
Block a user