fix: normalize malformed Bash tool arguments from OpenAI-compatible providers

This commit is contained in:
gnanam1990
2026-04-05 16:35:41 +05:30
parent 39f3b2babd
commit 91df124064
3 changed files with 1055 additions and 9 deletions

View File

@@ -500,6 +500,922 @@ test('preserves Gemini tool call extra_content from streaming chunks', async ()
})
})
test('normalizes plain string Bash tool arguments from OpenAI-compatible responses', async () => {
globalThis.fetch = (async (_input, _init) => {
return new Response(
JSON.stringify({
id: 'chatcmpl-1',
model: 'google/gemini-3.1-pro-preview',
choices: [
{
message: {
role: 'assistant',
tool_calls: [
{
id: 'function-call-1',
type: 'function',
function: {
name: 'Bash',
arguments: 'pwd',
},
},
],
},
finish_reason: 'tool_calls',
},
],
usage: {
prompt_tokens: 12,
completion_tokens: 4,
total_tokens: 16,
},
}),
{
headers: {
'Content-Type': 'application/json',
},
},
)
}) as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
const message = await client.beta.messages.create({
model: 'google/gemini-3.1-pro-preview',
system: 'test system',
messages: [{ role: 'user', content: 'Use Bash' }],
max_tokens: 64,
stream: false,
}) as {
stop_reason?: string
content?: Array<Record<string, unknown>>
}
expect(message.stop_reason).toBe('tool_use')
expect(message.content).toEqual([
{
type: 'tool_use',
id: 'function-call-1',
name: 'Bash',
input: { command: 'pwd' },
},
])
})
test('normalizes Bash tool arguments that are valid JSON literals', async () => {
globalThis.fetch = (async (_input, _init) => {
return new Response(
JSON.stringify({
id: 'chatcmpl-1',
model: 'google/gemini-3.1-pro-preview',
choices: [
{
message: {
role: 'assistant',
tool_calls: [
{
id: 'function-call-1',
type: 'function',
function: {
name: 'Bash',
arguments: '123',
},
},
],
},
finish_reason: 'tool_calls',
},
],
usage: {
prompt_tokens: 12,
completion_tokens: 4,
total_tokens: 16,
},
}),
{
headers: {
'Content-Type': 'application/json',
},
},
)
}) as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
const message = await client.beta.messages.create({
model: 'google/gemini-3.1-pro-preview',
system: 'test system',
messages: [{ role: 'user', content: 'Use Bash' }],
max_tokens: 64,
stream: false,
}) as {
content?: Array<Record<string, unknown>>
}
expect(message.content).toEqual([
{
type: 'tool_use',
id: 'function-call-1',
name: 'Bash',
input: { command: '123' },
},
])
})
test('normalizes plain string Bash tool arguments in streaming responses', async () => {
globalThis.fetch = (async (_input, _init) => {
const chunks = makeStreamChunks([
{
id: 'chatcmpl-1',
object: 'chat.completion.chunk',
model: 'google/gemini-3.1-pro-preview',
choices: [
{
index: 0,
delta: {
role: 'assistant',
tool_calls: [
{
index: 0,
id: 'function-call-1',
type: 'function',
function: {
name: 'Bash',
arguments: 'pwd',
},
},
],
},
finish_reason: null,
},
],
},
{
id: 'chatcmpl-1',
object: 'chat.completion.chunk',
model: 'google/gemini-3.1-pro-preview',
choices: [
{
index: 0,
delta: {},
finish_reason: 'tool_calls',
},
],
},
])
return makeSseResponse(chunks)
}) as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
const result = await client.beta.messages
.create({
model: 'google/gemini-3.1-pro-preview',
system: 'test system',
messages: [{ role: 'user', content: 'Use Bash' }],
max_tokens: 64,
stream: true,
})
.withResponse()
const events: Array<Record<string, unknown>> = []
for await (const event of result.data) {
events.push(event)
}
const normalizedInput = events
.filter(
event =>
event.type === 'content_block_delta' &&
typeof event.delta === 'object' &&
event.delta !== null &&
(event.delta as Record<string, unknown>).type === 'input_json_delta',
)
.map(event => (event.delta as Record<string, unknown>).partial_json)
.join('')
expect(normalizedInput).toBe('{"command":"pwd"}')
})
test('normalizes plain string Bash tool arguments when streaming starts with an empty chunk', async () => {
globalThis.fetch = (async (_input, _init) => {
const chunks = makeStreamChunks([
{
id: 'chatcmpl-1',
object: 'chat.completion.chunk',
model: 'google/gemini-3.1-pro-preview',
choices: [
{
index: 0,
delta: {
role: 'assistant',
tool_calls: [
{
index: 0,
id: 'function-call-1',
type: 'function',
function: {
name: 'Bash',
arguments: '',
},
},
],
},
finish_reason: null,
},
],
},
{
id: 'chatcmpl-1',
object: 'chat.completion.chunk',
model: 'google/gemini-3.1-pro-preview',
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: 0,
type: 'function',
function: {
arguments: 'pwd',
},
},
],
},
finish_reason: null,
},
],
},
{
id: 'chatcmpl-1',
object: 'chat.completion.chunk',
model: 'google/gemini-3.1-pro-preview',
choices: [
{
index: 0,
delta: {},
finish_reason: 'tool_calls',
},
],
},
])
return makeSseResponse(chunks)
}) as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
const result = await client.beta.messages
.create({
model: 'google/gemini-3.1-pro-preview',
system: 'test system',
messages: [{ role: 'user', content: 'Use Bash' }],
max_tokens: 64,
stream: true,
})
.withResponse()
const events: Array<Record<string, unknown>> = []
for await (const event of result.data) {
events.push(event)
}
const normalizedInput = events
.filter(
event =>
event.type === 'content_block_delta' &&
typeof event.delta === 'object' &&
event.delta !== null &&
(event.delta as Record<string, unknown>).type === 'input_json_delta',
)
.map(event => (event.delta as Record<string, unknown>).partial_json)
.join('')
expect(normalizedInput).toBe('{"command":"pwd"}')
})
test('normalizes plain string Bash tool arguments when streaming starts with whitespace', async () => {
globalThis.fetch = (async (_input, _init) => {
const chunks = makeStreamChunks([
{
id: 'chatcmpl-1',
object: 'chat.completion.chunk',
model: 'google/gemini-3.1-pro-preview',
choices: [
{
index: 0,
delta: {
role: 'assistant',
tool_calls: [
{
index: 0,
id: 'function-call-1',
type: 'function',
function: {
name: 'Bash',
arguments: ' ',
},
},
],
},
finish_reason: null,
},
],
},
{
id: 'chatcmpl-1',
object: 'chat.completion.chunk',
model: 'google/gemini-3.1-pro-preview',
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: 0,
type: 'function',
function: {
arguments: 'pwd',
},
},
],
},
finish_reason: null,
},
],
},
{
id: 'chatcmpl-1',
object: 'chat.completion.chunk',
model: 'google/gemini-3.1-pro-preview',
choices: [
{
index: 0,
delta: {},
finish_reason: 'tool_calls',
},
],
},
])
return makeSseResponse(chunks)
}) as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
const result = await client.beta.messages
.create({
model: 'google/gemini-3.1-pro-preview',
system: 'test system',
messages: [{ role: 'user', content: 'Use Bash' }],
max_tokens: 64,
stream: true,
})
.withResponse()
const events: Array<Record<string, unknown>> = []
for await (const event of result.data) {
events.push(event)
}
const normalizedInput = events
.filter(
event =>
event.type === 'content_block_delta' &&
typeof event.delta === 'object' &&
event.delta !== null &&
(event.delta as Record<string, unknown>).type === 'input_json_delta',
)
.map(event => (event.delta as Record<string, unknown>).partial_json)
.join('')
expect(normalizedInput).toBe('{"command":" pwd"}')
})
test('normalizes streaming Bash arguments that begin with bracket syntax', async () => {
globalThis.fetch = (async (_input, _init) => {
const chunks = makeStreamChunks([
{
id: 'chatcmpl-1',
object: 'chat.completion.chunk',
model: 'google/gemini-3.1-pro-preview',
choices: [
{
index: 0,
delta: {
role: 'assistant',
tool_calls: [
{
index: 0,
id: 'function-call-1',
type: 'function',
function: {
name: 'Bash',
arguments: '[ -f package.json ] && pwd',
},
},
],
},
finish_reason: null,
},
],
},
{
id: 'chatcmpl-1',
object: 'chat.completion.chunk',
model: 'google/gemini-3.1-pro-preview',
choices: [
{
index: 0,
delta: {},
finish_reason: 'tool_calls',
},
],
},
])
return makeSseResponse(chunks)
}) as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
const result = await client.beta.messages
.create({
model: 'google/gemini-3.1-pro-preview',
system: 'test system',
messages: [{ role: 'user', content: 'Use Bash' }],
max_tokens: 64,
stream: true,
})
.withResponse()
const events: Array<Record<string, unknown>> = []
for await (const event of result.data) {
events.push(event)
}
const normalizedInput = events
.filter(
event =>
event.type === 'content_block_delta' &&
typeof event.delta === 'object' &&
event.delta !== null &&
(event.delta as Record<string, unknown>).type === 'input_json_delta',
)
.map(event => (event.delta as Record<string, unknown>).partial_json)
.join('')
expect(normalizedInput).toBe('{"command":"[ -f package.json ] && pwd"}')
})
test('normalizes streaming Bash arguments when the first chunk is only an opening brace', async () => {
globalThis.fetch = (async (_input, _init) => {
const chunks = makeStreamChunks([
{
id: 'chatcmpl-1',
object: 'chat.completion.chunk',
model: 'google/gemini-3.1-pro-preview',
choices: [
{
index: 0,
delta: {
role: 'assistant',
tool_calls: [
{
index: 0,
id: 'function-call-1',
type: 'function',
function: {
name: 'Bash',
arguments: '{',
},
},
],
},
finish_reason: null,
},
],
},
{
id: 'chatcmpl-1',
object: 'chat.completion.chunk',
model: 'google/gemini-3.1-pro-preview',
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: 0,
type: 'function',
function: {
arguments: ' pwd; }',
},
},
],
},
finish_reason: null,
},
],
},
{
id: 'chatcmpl-1',
object: 'chat.completion.chunk',
model: 'google/gemini-3.1-pro-preview',
choices: [
{
index: 0,
delta: {},
finish_reason: 'tool_calls',
},
],
},
])
return makeSseResponse(chunks)
}) as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
const result = await client.beta.messages
.create({
model: 'google/gemini-3.1-pro-preview',
system: 'test system',
messages: [{ role: 'user', content: 'Use Bash' }],
max_tokens: 64,
stream: true,
})
.withResponse()
const events: Array<Record<string, unknown>> = []
for await (const event of result.data) {
events.push(event)
}
const normalizedInput = events
.filter(
event =>
event.type === 'content_block_delta' &&
typeof event.delta === 'object' &&
event.delta !== null &&
(event.delta as Record<string, unknown>).type === 'input_json_delta',
)
.map(event => (event.delta as Record<string, unknown>).partial_json)
.join('')
expect(normalizedInput).toBe('{"command":"{ pwd; }"}')
})
test('repairs truncated structured Bash JSON in streaming responses', async () => {
globalThis.fetch = (async (_input, _init) => {
const chunks = makeStreamChunks([
{
id: 'chatcmpl-1',
object: 'chat.completion.chunk',
model: 'google/gemini-3.1-pro-preview',
choices: [
{
index: 0,
delta: {
role: 'assistant',
tool_calls: [
{
index: 0,
id: 'function-call-1',
type: 'function',
function: {
name: 'Bash',
arguments: '{"command":"pwd"',
},
},
],
},
finish_reason: null,
},
],
},
{
id: 'chatcmpl-1',
object: 'chat.completion.chunk',
model: 'google/gemini-3.1-pro-preview',
choices: [
{
index: 0,
delta: {},
finish_reason: 'tool_calls',
},
],
},
])
return makeSseResponse(chunks)
}) as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
const result = await client.beta.messages
.create({
model: 'google/gemini-3.1-pro-preview',
system: 'test system',
messages: [{ role: 'user', content: 'Use Bash' }],
max_tokens: 64,
stream: true,
})
.withResponse()
const events: Array<Record<string, unknown>> = []
for await (const event of result.data) {
events.push(event)
}
const normalizedInput = events
.filter(
event =>
event.type === 'content_block_delta' &&
typeof event.delta === 'object' &&
event.delta !== null &&
(event.delta as Record<string, unknown>).type === 'input_json_delta',
)
.map(event => (event.delta as Record<string, unknown>).partial_json)
.join('')
expect(normalizedInput).toBe('{"command":"pwd"}')
})
test('does not normalize incomplete streamed Bash commands when finish_reason is length', async () => {
globalThis.fetch = (async (_input, _init) => {
const chunks = makeStreamChunks([
{
id: 'chatcmpl-1',
object: 'chat.completion.chunk',
model: 'google/gemini-3.1-pro-preview',
choices: [
{
index: 0,
delta: {
role: 'assistant',
tool_calls: [
{
index: 0,
id: 'function-call-1',
type: 'function',
function: {
name: 'Bash',
arguments: 'rg --fi',
},
},
],
},
finish_reason: null,
},
],
},
{
id: 'chatcmpl-1',
object: 'chat.completion.chunk',
model: 'google/gemini-3.1-pro-preview',
choices: [
{
index: 0,
delta: {},
finish_reason: 'length',
},
],
},
])
return makeSseResponse(chunks)
}) as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
const result = await client.beta.messages
.create({
model: 'google/gemini-3.1-pro-preview',
system: 'test system',
messages: [{ role: 'user', content: 'Use Bash' }],
max_tokens: 64,
stream: true,
})
.withResponse()
const events: Array<Record<string, unknown>> = []
for await (const event of result.data) {
events.push(event)
}
const streamedInput = events
.filter(
event =>
event.type === 'content_block_delta' &&
typeof event.delta === 'object' &&
event.delta !== null &&
(event.delta as Record<string, unknown>).type === 'input_json_delta',
)
.map(event => (event.delta as Record<string, unknown>).partial_json)
.join('')
expect(streamedInput).toBe('rg --fi')
})
test('does not repair truncated Bash objects that do not contain command', async () => {
globalThis.fetch = (async (_input, _init) => {
const chunks = makeStreamChunks([
{
id: 'chatcmpl-1',
object: 'chat.completion.chunk',
model: 'google/gemini-3.1-pro-preview',
choices: [
{
index: 0,
delta: {
role: 'assistant',
tool_calls: [
{
index: 0,
id: 'function-call-1',
type: 'function',
function: {
name: 'Bash',
arguments: '{"cwd":"/tmp"',
},
},
],
},
finish_reason: null,
},
],
},
{
id: 'chatcmpl-1',
object: 'chat.completion.chunk',
model: 'google/gemini-3.1-pro-preview',
choices: [
{
index: 0,
delta: {},
finish_reason: 'tool_calls',
},
],
},
])
return makeSseResponse(chunks)
}) as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
const result = await client.beta.messages
.create({
model: 'google/gemini-3.1-pro-preview',
system: 'test system',
messages: [{ role: 'user', content: 'Use Bash' }],
max_tokens: 64,
stream: true,
})
.withResponse()
const events: Array<Record<string, unknown>> = []
for await (const event of result.data) {
events.push(event)
}
const streamedInput = events
.filter(
event =>
event.type === 'content_block_delta' &&
typeof event.delta === 'object' &&
event.delta !== null &&
(event.delta as Record<string, unknown>).type === 'input_json_delta',
)
.map(event => (event.delta as Record<string, unknown>).partial_json)
.join('')
expect(streamedInput).toBe('{"cwd":"/tmp"')
})
test('preserves raw input for unknown plain string tool arguments', async () => {
globalThis.fetch = (async (_input, _init) => {
return new Response(
JSON.stringify({
id: 'chatcmpl-1',
model: 'google/gemini-3.1-pro-preview',
choices: [
{
message: {
role: 'assistant',
tool_calls: [
{
id: 'function-call-1',
type: 'function',
function: {
name: 'UnknownTool',
arguments: 'pwd',
},
},
],
},
finish_reason: 'tool_calls',
},
],
usage: {
prompt_tokens: 12,
completion_tokens: 4,
total_tokens: 16,
},
}),
{
headers: {
'Content-Type': 'application/json',
},
},
)
}) as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
const message = await client.beta.messages.create({
model: 'google/gemini-3.1-pro-preview',
system: 'test system',
messages: [{ role: 'user', content: 'Use tool' }],
max_tokens: 64,
stream: false,
}) as {
content?: Array<Record<string, unknown>>
}
expect(message.content).toEqual([
{
type: 'tool_use',
id: 'function-call-1',
name: 'UnknownTool',
input: { raw: 'pwd' },
},
])
})
test('preserves parsed string input for unknown JSON string tool arguments', async () => {
globalThis.fetch = (async (_input, _init) => {
return new Response(
JSON.stringify({
id: 'chatcmpl-1',
model: 'google/gemini-3.1-pro-preview',
choices: [
{
message: {
role: 'assistant',
tool_calls: [
{
id: 'function-call-1',
type: 'function',
function: {
name: 'UnknownTool',
arguments: '"pwd"',
},
},
],
},
finish_reason: 'tool_calls',
},
],
usage: {
prompt_tokens: 12,
completion_tokens: 4,
total_tokens: 16,
},
}),
{
headers: {
'Content-Type': 'application/json',
},
},
)
}) as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
const message = await client.beta.messages.create({
model: 'google/gemini-3.1-pro-preview',
system: 'test system',
messages: [{ role: 'user', content: 'Use tool' }],
max_tokens: 64,
stream: false,
}) as {
content?: Array<Record<string, unknown>>
}
expect(message.content).toEqual([
{
type: 'tool_use',
id: 'function-call-1',
name: 'UnknownTool',
input: 'pwd',
},
])
})
test('sanitizes malformed MCP tool schemas before sending them to OpenAI', async () => {
let requestBody: Record<string, unknown> | undefined