From dee1d9ba74239efcbad21d70d43dfd74bf24abec Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 25 Mar 2026 22:01:29 -0300 Subject: [PATCH 1/2] 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, }); From 2602cd9ab2338255184138cf92e40ee5ad0c3487 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 25 Mar 2026 22:05:00 -0300 Subject: [PATCH 2/2] =?UTF-8?q?chore(release):=20v3.0.8=20=E2=80=94=20fix?= =?UTF-8?q?=20translation=20failures=20for=20OpenAI-format=20providers=20(?= =?UTF-8?q?#632)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 13 +++++++++++++ docs/openapi.yaml | 2 +- package-lock.json | 4 ++-- package.json | 2 +- src/lib/db/combos.ts | 10 +++++----- src/lib/db/models.ts | 18 +++++++++--------- 6 files changed, 31 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0dc2e2a35..6440883715 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ --- +## [3.0.8] — 2026-03-25 + +### 🐛 Bug Fixes + +- **Translation Failures for OpenAI-format Providers in Claude CLI (#632):** + - Handle `reasoning_details[]` array format from StepFun/OpenRouter — converts to `reasoning_content` + - Handle `reasoning` field alias from some providers → normalized to `reasoning_content` + - Cross-map usage field names: `input_tokens`↔`prompt_tokens`, `output_tokens`↔`completion_tokens` in `filterUsageForFormat` + - Fix `extractUsage` to accept both `input_tokens`/`output_tokens` and `prompt_tokens`/`completion_tokens` as valid usage fields + - Applied to both streaming (`sanitizeStreamingChunk`, `openai-to-claude.ts` translator) and non-streaming (`sanitizeMessage`) paths + +--- + ## [3.0.7] — 2026-03-25 ### 🐛 Bug Fixes diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 0e1b017f15..99a56165a2 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: OmniRoute API - version: 3.0.7 + version: 3.0.8 description: | OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible endpoint that routes requests to multiple AI providers with load balancing, diff --git a/package-lock.json b/package-lock.json index 11bef9cfb6..228bbd53fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute", - "version": "3.0.7", + "version": "3.0.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute", - "version": "3.0.7", + "version": "3.0.8", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index 279dc83ece..6336177c42 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "omniroute", - "version": "3.0.7", + "version": "3.0.8", "description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.", "type": "module", "bin": { diff --git a/src/lib/db/combos.ts b/src/lib/db/combos.ts index d477568076..fb2ca528fb 100644 --- a/src/lib/db/combos.ts +++ b/src/lib/db/combos.ts @@ -27,21 +27,21 @@ export async function getCombos() { .map((row) => JSON.parse(row)); } -export async function getComboById(id) { +export async function getComboById(id: string) { const db = getDbInstance(); const row = db.prepare("SELECT data FROM combos WHERE id = ?").get(id); const payload = getSerializedData(row); return payload ? JSON.parse(payload) : null; } -export async function getComboByName(name) { +export async function getComboByName(name: string) { const db = getDbInstance(); const row = db.prepare("SELECT data FROM combos WHERE name = ?").get(name); const payload = getSerializedData(row); return payload ? JSON.parse(payload) : null; } -export async function createCombo(data) { +export async function createCombo(data: JsonRecord) { const db = getDbInstance(); const now = new Date().toISOString(); @@ -63,7 +63,7 @@ export async function createCombo(data) { return combo; } -export async function updateCombo(id, data) { +export async function updateCombo(id: string, data: JsonRecord) { const db = getDbInstance(); const existing = db.prepare("SELECT data FROM combos WHERE id = ?").get(id); if (!existing) return null; @@ -84,7 +84,7 @@ export async function updateCombo(id, data) { return merged; } -export async function deleteCombo(id) { +export async function deleteCombo(id: string) { const db = getDbInstance(); const result = db.prepare("DELETE FROM combos WHERE id = ?").run(id); if (result.changes === 0) return false; diff --git a/src/lib/db/models.ts b/src/lib/db/models.ts index 8f8874760e..1f3650cbf3 100644 --- a/src/lib/db/models.ts +++ b/src/lib/db/models.ts @@ -247,7 +247,7 @@ export async function getModelAliases() { return result; } -export async function setModelAlias(alias, model) { +export async function setModelAlias(alias: string, model: unknown) { const db = getDbInstance(); db.prepare( "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('modelAliases', ?, ?)" @@ -255,7 +255,7 @@ export async function setModelAlias(alias, model) { backupDbFile("pre-write"); } -export async function deleteModelAlias(alias) { +export async function deleteModelAlias(alias: string) { const db = getDbInstance(); db.prepare("DELETE FROM key_value WHERE namespace = 'modelAliases' AND key = ?").run(alias); backupDbFile("pre-write"); @@ -263,7 +263,7 @@ export async function deleteModelAlias(alias) { // ──────────────── MITM Alias ──────────────── -export async function getMitmAlias(toolName) { +export async function getMitmAlias(toolName?: string) { const db = getDbInstance(); if (toolName) { const row = db @@ -282,7 +282,7 @@ export async function getMitmAlias(toolName) { return result; } -export async function setMitmAliasAll(toolName, mappings) { +export async function setMitmAliasAll(toolName: string, mappings: unknown) { const db = getDbInstance(); db.prepare( "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('mitmAlias', ?, ?)" @@ -292,7 +292,7 @@ export async function setMitmAliasAll(toolName, mappings) { // ──────────────── Custom Models ──────────────── -export async function getCustomModels(providerId) { +export async function getCustomModels(providerId?: string) { const db = getDbInstance(); if (providerId) { const row = db @@ -342,7 +342,7 @@ export async function addCustomModel( const value = getKeyValue(row).value; const models = value ? JSON.parse(value) : []; - const exists = models.find((m) => m.id === modelId); + const exists = models.find((m: JsonRecord) => m.id === modelId); if (exists) return exists; const model = { @@ -430,7 +430,7 @@ export async function replaceCustomModels( return merged; } -export async function removeCustomModel(providerId, modelId) { +export async function removeCustomModel(providerId: string, modelId: string) { const db = getDbInstance(); const row = db .prepare("SELECT value FROM key_value WHERE namespace = 'customModels' AND key = ?") @@ -441,7 +441,7 @@ export async function removeCustomModel(providerId, modelId) { if (!value) return false; const models = JSON.parse(value); const before = models.length; - const filtered = models.filter((m) => m.id !== modelId); + const filtered = models.filter((m: JsonRecord) => m.id !== modelId); if (filtered.length === before) return false; @@ -476,7 +476,7 @@ export async function updateCustomModel( if (!value) return null; const models = JSON.parse(value); - const index = models.findIndex((m) => m.id === modelId); + const index = models.findIndex((m: JsonRecord) => m.id === modelId); if (index === -1) return null; const current = models[index];