mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
Merge branch 'claude-code-best:main' into main
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user