From dee1d9ba74239efcbad21d70d43dfd74bf24abec Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 25 Mar 2026 22:01:29 -0300 Subject: [PATCH] fix: translation failures for OpenAI-format providers in Claude CLI (#632) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Handle reasoning_details[] array (StepFun/OpenRouter format) in sanitizer and translator - Handle 'reasoning' field alias → reasoning_content in streaming and non-streaming paths - Cross-map input_tokens/output_tokens ↔ prompt_tokens/completion_tokens in filterUsageForFormat - Fix extractUsage to accept input_tokens/output_tokens as alternative field names - All 936 tests pass --- open-sse/handlers/responseSanitizer.ts | 58 +++++++++++++++++++ .../translator/response/openai-to-claude.ts | 13 ++++- open-sse/utils/usageTracking.ts | 49 ++++++++++++++-- 3 files changed, 114 insertions(+), 6 deletions(-) diff --git a/open-sse/handlers/responseSanitizer.ts b/open-sse/handlers/responseSanitizer.ts index 24138cda4f..780bb8afe8 100644 --- a/open-sse/handlers/responseSanitizer.ts +++ b/open-sse/handlers/responseSanitizer.ts @@ -172,6 +172,44 @@ function sanitizeMessage(msg: unknown): unknown { sanitized.reasoning_content = msgRecord.reasoning_content; } + // Handle 'reasoning' field alias (some providers use this instead of reasoning_content) + if ( + msgRecord.reasoning && + typeof msgRecord.reasoning === "string" && + !sanitized.reasoning_content + ) { + sanitized.reasoning_content = msgRecord.reasoning; + } + + // Handle reasoning_details[] array (StepFun/OpenRouter format) + // Structure: [{ type: "reasoning.text", text: "...", format: "unknown", index: 0 }] + if (Array.isArray(msgRecord.reasoning_details) && !sanitized.reasoning_content) { + const reasoningParts: string[] = []; + for (const detail of msgRecord.reasoning_details) { + const detailObj = detail && typeof detail === "object" ? (detail as JsonRecord) : null; + if (!detailObj) continue; + const detailType = typeof detailObj.type === "string" ? detailObj.type : ""; + const detailText = + typeof detailObj.text === "string" + ? detailObj.text + : typeof detailObj.content === "string" + ? detailObj.content + : ""; + if ( + detailText && + (detailType === "reasoning" || + detailType === "reasoning.text" || + detailType === "thinking" || + detailType === "") + ) { + reasoningParts.push(detailText); + } + } + if (reasoningParts.length > 0) { + sanitized.reasoning_content = reasoningParts.join(""); + } + } + // Preserve tool_calls if (msgRecord.tool_calls) { sanitized.tool_calls = msgRecord.tool_calls; @@ -258,6 +296,26 @@ export function sanitizeStreamingChunk(parsed: unknown): unknown { if (deltaRecord.content !== undefined) delta.content = deltaRecord.content; if (deltaRecord.reasoning_content !== undefined) { delta.reasoning_content = deltaRecord.reasoning_content; + } else if (typeof deltaRecord.reasoning === "string" && deltaRecord.reasoning) { + // Alias: some providers use 'reasoning' instead of 'reasoning_content' + delta.reasoning_content = deltaRecord.reasoning; + } else if (Array.isArray(deltaRecord.reasoning_details)) { + // StepFun/OpenRouter: reasoning_details[{type:"reasoning.text", text:"..."}] + const parts: string[] = []; + for (const detail of deltaRecord.reasoning_details) { + const d = detail && typeof detail === "object" ? (detail as JsonRecord) : null; + if (!d) continue; + const text = + typeof d.text === "string" + ? d.text + : typeof d.content === "string" + ? d.content + : ""; + if (text) parts.push(text); + } + if (parts.length > 0) { + delta.reasoning_content = parts.join(""); + } } if (deltaRecord.tool_calls !== undefined) delta.tool_calls = deltaRecord.tool_calls; if (deltaRecord.function_call !== undefined) diff --git a/open-sse/translator/response/openai-to-claude.ts b/open-sse/translator/response/openai-to-claude.ts index 3d4afd154d..f3e8ed81ec 100644 --- a/open-sse/translator/response/openai-to-claude.ts +++ b/open-sse/translator/response/openai-to-claude.ts @@ -93,7 +93,18 @@ export function openaiToClaudeResponse(chunk, state) { } // Handle reasoning_content (thinking) - GLM, DeepSeek, etc. - const reasoningContent = delta?.reasoning_content || delta?.reasoning; + // Also supports 'reasoning' field alias and reasoning_details[] (StepFun/OpenRouter) + let reasoningContent = delta?.reasoning_content || delta?.reasoning; + if (!reasoningContent && Array.isArray(delta?.reasoning_details)) { + const parts: string[] = []; + for (const detail of delta.reasoning_details) { + if (detail && typeof detail === "object") { + const text = detail.text || detail.content; + if (typeof text === "string" && text) parts.push(text); + } + } + if (parts.length > 0) reasoningContent = parts.join(""); + } if (reasoningContent) { stopTextBlock(state, results); diff --git a/open-sse/utils/usageTracking.ts b/open-sse/utils/usageTracking.ts index 19c97b6fb2..b7d2791cbb 100644 --- a/open-sse/utils/usageTracking.ts +++ b/open-sse/utils/usageTracking.ts @@ -66,12 +66,47 @@ export function addBufferToUsage(usage) { export function filterUsageForFormat(usage, targetFormat) { if (!usage || typeof usage !== "object") return usage; + // Cross-map between Claude-style and OpenAI-style field names before filtering. + // Some providers return input_tokens/output_tokens even when using OpenAI format. + const convertedUsage = { ...usage }; + if (targetFormat === FORMATS.CLAUDE || targetFormat === FORMATS.OPENAI_RESPONSES) { + // OpenAI → Claude: prompt_tokens → input_tokens + if (convertedUsage.prompt_tokens !== undefined && convertedUsage.input_tokens === undefined) { + convertedUsage.input_tokens = convertedUsage.prompt_tokens; + } + if ( + convertedUsage.completion_tokens !== undefined && + convertedUsage.output_tokens === undefined + ) { + convertedUsage.output_tokens = convertedUsage.completion_tokens; + } + } else { + // Claude → OpenAI: input_tokens → prompt_tokens + if (convertedUsage.input_tokens !== undefined && convertedUsage.prompt_tokens === undefined) { + convertedUsage.prompt_tokens = convertedUsage.input_tokens; + } + if ( + convertedUsage.output_tokens !== undefined && + convertedUsage.completion_tokens === undefined + ) { + convertedUsage.completion_tokens = convertedUsage.output_tokens; + } + // Ensure total_tokens is set + if ( + convertedUsage.total_tokens === undefined && + convertedUsage.prompt_tokens !== undefined && + convertedUsage.completion_tokens !== undefined + ) { + convertedUsage.total_tokens = convertedUsage.prompt_tokens + convertedUsage.completion_tokens; + } + } + // Helper to pick only defined fields from usage const pickFields = (fields) => { const filtered = {}; for (const field of fields) { - if (usage[field] !== undefined) { - filtered[field] = usage[field]; + if (convertedUsage[field] !== undefined) { + filtered[field] = convertedUsage[field]; } } return filtered; @@ -230,10 +265,14 @@ export function extractUsage(chunk) { } // OpenAI format - if (chunk.usage && typeof chunk.usage === "object" && chunk.usage.prompt_tokens !== undefined) { + if ( + chunk.usage && + typeof chunk.usage === "object" && + (chunk.usage.prompt_tokens !== undefined || chunk.usage.input_tokens !== undefined) + ) { return normalizeUsage({ - prompt_tokens: chunk.usage.prompt_tokens, - completion_tokens: chunk.usage.completion_tokens || 0, + prompt_tokens: chunk.usage.prompt_tokens ?? chunk.usage.input_tokens ?? 0, + completion_tokens: chunk.usage.completion_tokens ?? chunk.usage.output_tokens ?? 0, cached_tokens: chunk.usage.prompt_tokens_details?.cached_tokens, reasoning_tokens: chunk.usage.completion_tokens_details?.reasoning_tokens, });