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

@@ -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,