Skip to content

Commit 6e598fc

Browse files
Merge pull request #149 from uk0/fix/openai-tool-compat
fix: OpenAI adapter tool calling compatibility
2 parents 919011a + e88dcb2 commit 6e598fc

4 files changed

Lines changed: 165 additions & 3 deletions

File tree

src/services/api/openai/__tests__/convertTools.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,88 @@ describe('anthropicToolsToOpenAI', () => {
5959
test('handles empty tools array', () => {
6060
expect(anthropicToolsToOpenAI([])).toEqual([])
6161
})
62+
63+
test('sanitizes const to enum in tool schema', () => {
64+
const tools = [
65+
{
66+
type: 'custom',
67+
name: 'test',
68+
description: 'test tool',
69+
input_schema: {
70+
type: 'object',
71+
properties: {
72+
mode: { const: 'read' },
73+
name: { type: 'string' },
74+
},
75+
},
76+
},
77+
]
78+
const result = anthropicToolsToOpenAI(tools as any)
79+
const props = result[0].function.parameters as any
80+
expect(props.properties.mode).toEqual({ enum: ['read'] })
81+
expect(props.properties.mode.const).toBeUndefined()
82+
expect(props.properties.name).toEqual({ type: 'string' })
83+
})
84+
85+
test('sanitizes const in deeply nested schemas', () => {
86+
const tools = [
87+
{
88+
type: 'custom',
89+
name: 'deep',
90+
description: 'nested const',
91+
input_schema: {
92+
type: 'object',
93+
properties: {
94+
outer: {
95+
type: 'object',
96+
properties: {
97+
inner: { const: 'fixed' },
98+
},
99+
},
100+
},
101+
definitions: {
102+
MyType: {
103+
type: 'object',
104+
properties: {
105+
field: { const: 42 },
106+
},
107+
},
108+
},
109+
},
110+
},
111+
]
112+
const result = anthropicToolsToOpenAI(tools as any)
113+
const params = result[0].function.parameters as any
114+
expect(params.properties.outer.properties.inner).toEqual({ enum: ['fixed'] })
115+
expect(params.definitions.MyType.properties.field).toEqual({ enum: [42] })
116+
})
117+
118+
test('sanitizes const in anyOf/oneOf/allOf', () => {
119+
const tools = [
120+
{
121+
type: 'custom',
122+
name: 'union',
123+
description: 'union test',
124+
input_schema: {
125+
type: 'object',
126+
properties: {
127+
val: {
128+
anyOf: [
129+
{ const: 'a' },
130+
{ const: 'b' },
131+
{ type: 'string' },
132+
],
133+
},
134+
},
135+
},
136+
},
137+
]
138+
const result = anthropicToolsToOpenAI(tools as any)
139+
const anyOf = (result[0].function.parameters as any).properties.val.anyOf
140+
expect(anyOf[0]).toEqual({ enum: ['a'] })
141+
expect(anyOf[1]).toEqual({ enum: ['b'] })
142+
expect(anyOf[2]).toEqual({ type: 'string' })
143+
})
62144
})
63145

64146
describe('anthropicToolChoiceToOpenAI', () => {

src/services/api/openai/__tests__/streamAdapter.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,28 @@ describe('adaptOpenAIStreamToAnthropic', () => {
165165
expect(msgDelta.delta.stop_reason).toBe('end_turn')
166166
})
167167

168+
test('forces tool_use stop_reason when tool_calls present but finish_reason is stop', async () => {
169+
// Some backends (e.g., certain OpenAI-compatible endpoints) incorrectly
170+
// return finish_reason "stop" when they actually made tool calls.
171+
const events = await collectEvents([
172+
makeChunk({
173+
choices: [{
174+
index: 0,
175+
delta: {
176+
tool_calls: [{ index: 0, id: 'call_1', function: { name: 'bash', arguments: '{"cmd":"ls"}' } }],
177+
},
178+
finish_reason: null,
179+
}],
180+
}),
181+
makeChunk({
182+
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
183+
}),
184+
])
185+
186+
const msgDelta = events.find(e => e.type === 'message_delta') as any
187+
expect(msgDelta.delta.stop_reason).toBe('tool_use')
188+
})
189+
168190
test('maps finish_reason tool_calls to tool_use', async () => {
169191
const events = await collectEvents([
170192
makeChunk({

src/services/api/openai/convertTools.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,66 @@ export function anthropicToolsToOpenAI(
2929
function: {
3030
name,
3131
description,
32-
parameters: inputSchema || { type: 'object', properties: {} },
32+
parameters: sanitizeJsonSchema(inputSchema || { type: 'object', properties: {} }),
3333
},
3434
} satisfies ChatCompletionTool
3535
})
3636
}
3737

38+
/**
39+
* Recursively sanitize a JSON Schema for OpenAI-compatible providers.
40+
*
41+
* Many OpenAI-compatible endpoints (Ollama, DeepSeek, vLLM, etc.) do not
42+
* support the `const` keyword in JSON Schema. Convert it to `enum` with a
43+
* single-element array, which is semantically equivalent.
44+
*/
45+
function sanitizeJsonSchema(schema: Record<string, unknown>): Record<string, unknown> {
46+
if (!schema || typeof schema !== 'object') return schema
47+
48+
const result = { ...schema }
49+
50+
// Convert `const` → `enum: [value]`
51+
if ('const' in result) {
52+
result.enum = [result.const]
53+
delete result.const
54+
}
55+
56+
// Recursively process nested schemas
57+
const objectKeys = ['properties', 'definitions', '$defs', 'patternProperties'] as const
58+
for (const key of objectKeys) {
59+
const nested = result[key]
60+
if (nested && typeof nested === 'object') {
61+
const sanitized: Record<string, unknown> = {}
62+
for (const [k, v] of Object.entries(nested as Record<string, unknown>)) {
63+
sanitized[k] = v && typeof v === 'object' ? sanitizeJsonSchema(v as Record<string, unknown>) : v
64+
}
65+
result[key] = sanitized
66+
}
67+
}
68+
69+
// Recursively process single-schema keys
70+
const singleKeys = ['items', 'additionalProperties', 'not', 'if', 'then', 'else', 'contains', 'propertyNames'] as const
71+
for (const key of singleKeys) {
72+
const nested = result[key]
73+
if (nested && typeof nested === 'object' && !Array.isArray(nested)) {
74+
result[key] = sanitizeJsonSchema(nested as Record<string, unknown>)
75+
}
76+
}
77+
78+
// Recursively process array-of-schemas keys
79+
const arrayKeys = ['anyOf', 'oneOf', 'allOf'] as const
80+
for (const key of arrayKeys) {
81+
const nested = result[key]
82+
if (Array.isArray(nested)) {
83+
result[key] = nested.map(item =>
84+
item && typeof item === 'object' ? sanitizeJsonSchema(item as Record<string, unknown>) : item
85+
)
86+
}
87+
}
88+
89+
return result
90+
}
91+
3892
/**
3993
* Map Anthropic tool_choice to OpenAI tool_choice format.
4094
*

src/services/api/openai/streamAdapter.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -257,8 +257,12 @@ export async function* adaptOpenAIStreamToAnthropic(
257257
}
258258
}
259259

260-
// Map finish_reason to Anthropic stop_reason
261-
const stopReason = mapFinishReason(choice.finish_reason)
260+
// Map finish_reason to Anthropic stop_reason.
261+
// Some backends return "stop" even when tool_calls are present —
262+
// force "tool_use" when we saw any tool blocks to ensure the query
263+
// loop actually executes the tools.
264+
const hasToolCalls = toolBlocks.size > 0
265+
const stopReason = hasToolCalls ? 'tool_use' : mapFinishReason(choice.finish_reason)
262266

263267
yield {
264268
type: 'message_delta',

0 commit comments

Comments
 (0)