Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions src/services/api/openai/__tests__/convertTools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
22 changes: 22 additions & 0 deletions src/services/api/openai/__tests__/streamAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
56 changes: 55 additions & 1 deletion src/services/api/openai/convertTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
8 changes: 6 additions & 2 deletions src/services/api/openai/streamAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +264 to +265
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Scope the tool_use override to finish_reason === 'stop' only.

Current logic overrides length/content_filter too when tool blocks exist, which can misreport truncated/filtered completions as executable tool calls.

💡 Proposed fix
-      const hasToolCalls = toolBlocks.size > 0
-      const stopReason = hasToolCalls ? 'tool_use' : mapFinishReason(choice.finish_reason)
+      const hasToolCalls = toolBlocks.size > 0
+      const mappedStopReason = mapFinishReason(choice.finish_reason)
+      const stopReason =
+        hasToolCalls && choice.finish_reason === 'stop'
+          ? 'tool_use'
+          : mappedStopReason
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const hasToolCalls = toolBlocks.size > 0
const stopReason = hasToolCalls ? 'tool_use' : mapFinishReason(choice.finish_reason)
const hasToolCalls = toolBlocks.size > 0
const mappedStopReason = mapFinishReason(choice.finish_reason)
const stopReason =
hasToolCalls && choice.finish_reason === 'stop'
? 'tool_use'
: mappedStopReason
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/services/api/openai/streamAdapter.ts` around lines 264 - 265, The current
logic sets stopReason = hasToolCalls ? 'tool_use' :
mapFinishReason(choice.finish_reason) regardless of the original finish reason;
change this so the 'tool_use' override is applied only when toolBlocks.size > 0
AND choice.finish_reason === 'stop' (otherwise preserve mapped reasons like
'length' or 'content_filter'). In other words, update the computation of
stopReason (symbols: toolBlocks, hasToolCalls, stopReason, mapFinishReason,
choice.finish_reason) so it checks choice.finish_reason === 'stop' before
substituting 'tool_use', and fall back to mapFinishReason(choice.finish_reason)
for all other finish reasons.


yield {
type: 'message_delta',
Expand Down
Loading