fix: normalize malformed Bash tool arguments from OpenAI-compatible providers
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -42,6 +42,9 @@ import {
|
||||
} from './providerConfig.js'
|
||||
import { sanitizeSchemaForOpenAICompat } from '../../utils/schemaSanitizer.js'
|
||||
import { redactSecretValueForDisplay } from '../../utils/providerProfile.js'
|
||||
import {
|
||||
normalizeToolArguments,
|
||||
} from './toolArgumentNormalization.js'
|
||||
|
||||
type SecretValueSource = Partial<{
|
||||
OPENAI_API_KEY: string
|
||||
@@ -476,6 +479,43 @@ function convertChunkUsage(
|
||||
}
|
||||
}
|
||||
|
||||
function repairPossiblyTruncatedObjectJson(raw: string): string | null {
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) && typeof (parsed as Record<string, unknown>).command === 'string'
|
||||
? raw
|
||||
: null
|
||||
} catch {
|
||||
const combinations = [
|
||||
'}',
|
||||
'"}',
|
||||
']}',
|
||||
'"]}',
|
||||
'}}',
|
||||
'"}}',
|
||||
']}}',
|
||||
'"]}}',
|
||||
'"]}]}',
|
||||
'}]}',
|
||||
]
|
||||
for (const combo of combinations) {
|
||||
try {
|
||||
const repaired = raw + combo
|
||||
const parsed = JSON.parse(repaired)
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === 'object' &&
|
||||
!Array.isArray(parsed) &&
|
||||
typeof (parsed as Record<string, unknown>).command === 'string'
|
||||
) {
|
||||
return repaired
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Async generator that transforms an OpenAI SSE stream into
|
||||
* Anthropic-format BetaRawMessageStreamEvent objects.
|
||||
@@ -486,7 +526,16 @@ async function* openaiStreamToAnthropic(
|
||||
): AsyncGenerator<AnthropicStreamEvent> {
|
||||
const messageId = makeMessageId()
|
||||
let contentBlockIndex = 0
|
||||
const activeToolCalls = new Map<number, { id: string; name: string; index: number; jsonBuffer: string }>()
|
||||
const activeToolCalls = new Map<
|
||||
number,
|
||||
{
|
||||
id: string
|
||||
name: string
|
||||
index: number
|
||||
jsonBuffer: string
|
||||
normalizeAtStop: boolean
|
||||
}
|
||||
>()
|
||||
let hasEmittedContentStart = false
|
||||
let lastStopReason: 'tool_use' | 'max_tokens' | 'end_turn' | null = null
|
||||
let hasEmittedFinalUsage = false
|
||||
@@ -577,11 +626,14 @@ async function* openaiStreamToAnthropic(
|
||||
}
|
||||
|
||||
const toolBlockIndex = contentBlockIndex
|
||||
const initialArguments = tc.function.arguments ?? ''
|
||||
const normalizeAtStop = tc.function.name === 'Bash'
|
||||
activeToolCalls.set(tc.index, {
|
||||
id: tc.id,
|
||||
name: tc.function.name,
|
||||
index: toolBlockIndex,
|
||||
jsonBuffer: tc.function.arguments ?? '',
|
||||
jsonBuffer: initialArguments,
|
||||
normalizeAtStop,
|
||||
})
|
||||
|
||||
yield {
|
||||
@@ -598,7 +650,7 @@ async function* openaiStreamToAnthropic(
|
||||
contentBlockIndex++
|
||||
|
||||
// Emit any initial arguments
|
||||
if (tc.function.arguments) {
|
||||
if (tc.function.arguments && !normalizeAtStop) {
|
||||
yield {
|
||||
type: 'content_block_delta',
|
||||
index: toolBlockIndex,
|
||||
@@ -615,6 +667,11 @@ async function* openaiStreamToAnthropic(
|
||||
if (tc.function.arguments) {
|
||||
active.jsonBuffer += tc.function.arguments
|
||||
}
|
||||
|
||||
if (active.normalizeAtStop) {
|
||||
continue
|
||||
}
|
||||
|
||||
yield {
|
||||
type: 'content_block_delta',
|
||||
index: active.index,
|
||||
@@ -642,6 +699,33 @@ async function* openaiStreamToAnthropic(
|
||||
}
|
||||
// Close active tool calls
|
||||
for (const [, tc] of activeToolCalls) {
|
||||
if (tc.normalizeAtStop) {
|
||||
let partialJson = tc.jsonBuffer
|
||||
if (choice.finish_reason === 'tool_calls') {
|
||||
const repairedStructuredJson = repairPossiblyTruncatedObjectJson(
|
||||
tc.jsonBuffer,
|
||||
)
|
||||
if (repairedStructuredJson) {
|
||||
partialJson = repairedStructuredJson
|
||||
} else if (!/^\s*\{\s*"/.test(tc.jsonBuffer)) {
|
||||
partialJson = JSON.stringify(
|
||||
normalizeToolArguments(tc.name, tc.jsonBuffer),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
yield {
|
||||
type: 'content_block_delta',
|
||||
index: tc.index,
|
||||
delta: {
|
||||
type: 'input_json_delta',
|
||||
partial_json: partialJson,
|
||||
},
|
||||
}
|
||||
yield { type: 'content_block_stop', index: tc.index }
|
||||
continue
|
||||
}
|
||||
|
||||
let suffixToAdd = ''
|
||||
if (tc.jsonBuffer) {
|
||||
try {
|
||||
@@ -1087,12 +1171,10 @@ class OpenAIShimMessages {
|
||||
|
||||
if (choice?.message?.tool_calls) {
|
||||
for (const tc of choice.message.tool_calls) {
|
||||
let input: unknown
|
||||
try {
|
||||
input = JSON.parse(tc.function.arguments)
|
||||
} catch {
|
||||
input = { raw: tc.function.arguments }
|
||||
}
|
||||
const input = normalizeToolArguments(
|
||||
tc.function.name,
|
||||
tc.function.arguments,
|
||||
)
|
||||
content.push({
|
||||
type: 'tool_use',
|
||||
id: tc.id,
|
||||
|
||||
48
src/services/api/toolArgumentNormalization.ts
Normal file
48
src/services/api/toolArgumentNormalization.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
const STRING_ARGUMENT_TOOL_FIELDS: Record<string, string> = {
|
||||
Bash: 'command',
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function getPlainStringToolArgumentField(toolName: string): string | null {
|
||||
return STRING_ARGUMENT_TOOL_FIELDS[toolName] ?? null
|
||||
}
|
||||
|
||||
function wrapPlainStringToolArguments(
|
||||
toolName: string,
|
||||
value: string,
|
||||
): Record<string, string> | null {
|
||||
const field = getPlainStringToolArgumentField(toolName)
|
||||
if (!field) return null
|
||||
return { [field]: value }
|
||||
}
|
||||
|
||||
export function normalizeToolArguments(
|
||||
toolName: string,
|
||||
rawArguments: string | undefined,
|
||||
): unknown {
|
||||
if (rawArguments === undefined) return {}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rawArguments)
|
||||
if (isRecord(parsed)) {
|
||||
return parsed
|
||||
}
|
||||
if (toolName === 'Bash') {
|
||||
if (typeof parsed === 'string') {
|
||||
return wrapPlainStringToolArguments(toolName, parsed) ?? parsed
|
||||
}
|
||||
return wrapPlainStringToolArguments(toolName, rawArguments) ?? rawArguments
|
||||
}
|
||||
if (typeof parsed === 'string') {
|
||||
return wrapPlainStringToolArguments(toolName, parsed) ?? parsed
|
||||
}
|
||||
return parsed
|
||||
} catch {
|
||||
return (
|
||||
wrapPlainStringToolArguments(toolName, rawArguments) ?? { raw: rawArguments }
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user