diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts index 1bde1380..c8f60cd5 100644 --- a/src/services/api/openaiShim.test.ts +++ b/src/services/api/openaiShim.test.ts @@ -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> + } + + 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> + } + + 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> = [] + 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).type === 'input_json_delta', + ) + .map(event => (event.delta as Record).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> = [] + 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).type === 'input_json_delta', + ) + .map(event => (event.delta as Record).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> = [] + 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).type === 'input_json_delta', + ) + .map(event => (event.delta as Record).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> = [] + 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).type === 'input_json_delta', + ) + .map(event => (event.delta as Record).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> = [] + 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).type === 'input_json_delta', + ) + .map(event => (event.delta as Record).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> = [] + 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).type === 'input_json_delta', + ) + .map(event => (event.delta as Record).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> = [] + 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).type === 'input_json_delta', + ) + .map(event => (event.delta as Record).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> = [] + 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).type === 'input_json_delta', + ) + .map(event => (event.delta as Record).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> + } + + 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> + } + + 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 | undefined diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index 8c2e07e3..6a17f405 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -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).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).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 { const messageId = makeMessageId() let contentBlockIndex = 0 - const activeToolCalls = new Map() + 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, diff --git a/src/services/api/toolArgumentNormalization.ts b/src/services/api/toolArgumentNormalization.ts new file mode 100644 index 00000000..f5dcd73e --- /dev/null +++ b/src/services/api/toolArgumentNormalization.ts @@ -0,0 +1,48 @@ +const STRING_ARGUMENT_TOOL_FIELDS: Record = { + Bash: 'command', +} + +function isRecord(value: unknown): value is Record { + 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 | 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 } + ) + } +}