Skip to content
Open
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
95 changes: 95 additions & 0 deletions src/services/api/openAICompatInferenceClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -920,6 +920,101 @@ describe('mapOpenAIChatCompletionToAnthropicMessage', () => {

expect(message.content).toEqual([{ type: 'text', text: 'final answer' }])
})

it('strips malformed tool_use blocks and matching tool_results before sending history', () => {
const request = buildOpenAICompatChatRequest({
model: GLM_5_2_MODEL,
max_tokens: 64,
messages: [
{ role: 'user', content: 'do something' },
{
role: 'assistant',
content: [
{ type: 'tool_use', id: 'blank_name', name: '', input: {} },
{ type: 'tool_use', id: 'missing_name', input: {} },
{ type: 'tool_use', id: 'non_string_name', name: 123, input: {} },
],
},
{
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: 'blank_name',
content: 'Tool not found',
},
{
type: 'tool_result',
tool_use_id: 'missing_name',
content: 'Tool not found',
},
{
type: 'tool_result',
tool_use_id: 'non_string_name',
content: 'Tool not found',
},
{ type: 'text', text: 'continue' },
],
},
{ role: 'user', content: 'next question' },
],
} as never)

expect(request.messages).toEqual([
{ role: 'user', content: 'do something' },
{ role: 'user', content: 'continue' },
{ role: 'user', content: 'next question' },
])
})

it('preserves valid tool_use blocks alongside malformed blocks', () => {
const request = buildOpenAICompatChatRequest({
model: GLM_5_2_MODEL,
max_tokens: 64,
messages: [
{ role: 'user', content: 'hello' },
{
role: 'assistant',
content: [
{ type: 'tool_use', id: 'bad', name: ' ', input: {} },
{ type: 'tool_use', id: 'good', name: 'Bash', input: { cmd: 'ls' } },
],
},
{
role: 'user',
content: [
{ type: 'tool_result', tool_use_id: 'bad', content: 'Tool not found' },
{ type: 'tool_result', tool_use_id: 'good', content: 'file.ts' },
],
},
],
tools: [
{
name: 'Bash',
description: 'Run shell commands',
input_schema: {
type: 'object',
properties: { cmd: { type: 'string' } },
},
},
],
} as never)

const assistantMsg = request.messages.find(m => m.role === 'assistant')
expect(assistantMsg).toBeDefined()
expect(assistantMsg?.tool_calls).toEqual([
{
id: 'good',
type: 'function',
function: { name: 'Bash', arguments: '{"cmd":"ls"}' },
},
])

const toolMsg = request.messages.find(m => m.role === 'tool')
expect(toolMsg).toBeDefined()
expect(toolMsg?.tool_call_id).toBe('good')
expect(toolMsg?.content).toBe('file.ts')
})
})

describe('OpenAICompatInferenceClient', () => {
Expand Down
99 changes: 98 additions & 1 deletion src/services/api/openAICompatInferenceClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -681,6 +681,100 @@ function convertMessages(
return converted
}

function isMalformedToolUseBlock(block: unknown): block is {
id?: unknown
type: 'tool_use'
} {
if (!block || typeof block !== 'object') return false
if (!('type' in block) || (block as { type?: unknown }).type !== 'tool_use') {
return false
}
return !('name' in block) || typeof (block as { name?: unknown }).name !== 'string' || (block as { name: string }).name.trim() === ''
}

/**
* Strips malformed assistant tool_use blocks before forwarding history to an
* OpenAI-compatible endpoint, and removes matching tool_result blocks. This
* prevents malformed model output (blank/missing/non-string tool names) from
* poisoning all later requests with history that OpenAI-compatible servers
* reject before the model can recover.
*/
function sanitizeMessagesForMalformedToolCalls(messages: unknown): unknown {
if (!Array.isArray(messages)) return messages

const orphanedIds = new Set<string>()
const result: unknown[] = []

for (const message of messages) {
if (
!message ||
typeof message !== 'object' ||
!('role' in message) ||
!('content' in message)
) {
result.push(message)
continue
}

const msg = message as {
role: unknown
content: unknown
[key: string]: unknown
}

if (msg.role === 'assistant' && Array.isArray(msg.content)) {
const filteredContent = msg.content.filter((block: unknown) => {
if (isMalformedToolUseBlock(block)) {
if ('id' in block && typeof block.id === 'string') {
orphanedIds.add(block.id)
}
return false
}
return true
})

if (filteredContent.length === 0) {
continue
}

result.push({ ...msg, content: filteredContent })
continue
}

if (
msg.role === 'user' &&
Array.isArray(msg.content) &&
orphanedIds.size > 0
) {
const filteredContent = msg.content.filter((block: unknown) => {
if (
block &&
typeof block === 'object' &&
'type' in block &&
(block as { type: unknown }).type === 'tool_result' &&
'tool_use_id' in block &&
typeof (block as { tool_use_id: unknown }).tool_use_id === 'string' &&
orphanedIds.has((block as { tool_use_id: string }).tool_use_id)
) {
return false
}
return true
})

if (filteredContent.length === 0) {
continue
}

result.push({ ...msg, content: filteredContent })
continue
}

result.push(message)
}

return result
}

function convertTools(tools: unknown): Array<Record<string, unknown>> | undefined {
if (!Array.isArray(tools) || tools.length === 0) {
return undefined
Expand Down Expand Up @@ -845,7 +939,10 @@ export function buildOpenAICompatChatRequest(
: undefined
const convertedTools = convertTools(params.tools)
const convertedToolChoice = convertToolChoice(params.tool_choice)
const convertedMessages = convertMessages(params.system, params.messages)
const convertedMessages = convertMessages(
params.system,
sanitizeMessagesForMalformedToolCalls(params.messages),
)

const request: OpenAIChatCompletionRequest = {
model: normalizeOpenAICompatModelForAPI(params.model, {
Expand Down
Loading
Loading