diff --git a/CHANGELOG.md b/CHANGELOG.md index 69ece42f8a..7c8cda9a81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ _In development — bullets added per PR; finalized at release._ - **fix(dashboard):** surface manual config CTA when Claude CLI detection fails (remote deployments). (thanks @anuragg-saxenaa) - **fix(executors):** granular reasoning_effort handling for Claude models on GitHub Copilot. (thanks @baslr) - **fix(translator):** strip Claude output_config before MiniMax (rejected upstream). (thanks @hiepau1231) +- **fix(translator): OpenAI audio input now reaches Gemini/Antigravity instead of being silently dropped** — `input_audio`/`audio` content parts on the OpenAI→Gemini path matched no handler in `convertOpenAIContentToParts` and were discarded with no error. They are now mapped to a Gemini `inlineData` part with an `audio/` mime type (wav, mp3, …). (thanks @mugnimaestra) - **fix(combo): round-robin members fail over faster under concurrency saturation via a configurable queue depth** — when a round-robin combo member was saturated, requests sat in the per-model semaphore's **unbounded** queue and only failed over to the next member after the full `queueTimeoutMs` (default 30s) elapsed — so a burst of agentic requests deep-queued one hot member instead of spilling to healthy ones. The per-model semaphore now accepts a bounded queue depth and emits `SEMAPHORE_QUEUE_FULL` once it is full (the round-robin loop already cascades on that code), so a configured low depth fails over immediately. A new `queueDepth` combo-config knob (global default / provider override / per-combo, default **20** for backward compatibility; **0** = never queue → fail over now) is exposed in Settings → Combo Defaults. ([#3872](https://github.com/diegosouzapw/OmniRoute/issues/3872) — thanks @KooshaPari) - **fix(pricing): align Claude Code (`cc`) pricing with current Anthropic per-MTok rates** — the `cc` provider block in the default pricing table had stale numbers across every Claude 4.x family entry — most visibly, `claude-opus-4-5-20251101` was billed at the deprecated Opus 4.1 rate (`input $15` / `output $75`), and `claude-haiku-4-5-20251001` was at half the current Haiku 4.5 rate. The `cached` (cache hit) and `cache_creation` (5-minute cache write) multipliers were also off across Opus 4.6/4.7/4.8, Sonnet 4.5/4.6, Haiku 4.5, and Fable 5. All eight entries now match the rates Anthropic publishes (input, 5m cache write at 1.25x input, cache hit at 0.1x input, output; reasoning billed at the output rate), so cost accounting on the dashboard and per-request usage events stop under- or over-reporting Claude Code spend. (thanks @chulanpro5) - **fix(executors): sanitize Anthropic-shape content parts before GitHub Copilot `/chat/completions`** — Claude models on GitHub Copilot driven from clients like Cursor IDE (e.g. `gh/claude-sonnet-4.6`) failed with `Provider returned error: type has to be either 'image_url' or 'text' (reset after 30s)` because the client passed through Anthropic-shape content parts (`tool_use`, `tool_result`, `thinking`) untouched, and the Copilot chat-completions endpoint only accepts `text`/`image_url`. `GithubExecutor.transformRequest` now serializes any unsupported part type as `text` (preserving the model's context), drops empty parts, and collapses to `null` when an assistant message's only content was tool_calls — `tool_calls` ride alongside untouched. Codex-family models still route through `/responses` unchanged. (thanks @cngznNN) diff --git a/open-sse/translator/helpers/geminiHelper.ts b/open-sse/translator/helpers/geminiHelper.ts index eb29936904..1efb572181 100644 --- a/open-sse/translator/helpers/geminiHelper.ts +++ b/open-sse/translator/helpers/geminiHelper.ts @@ -102,6 +102,26 @@ export function convertOpenAIContentToParts(content: unknown): JsonRecord[] { if (rec.type === "text") { parts.push({ text: rec.text }); } else { + // 0. Handle OpenAI audio input parts → Gemini inlineData (#912). + // Chat Completions shape: {type:"input_audio", input_audio:{data, format}}. + // Some clients use {type:"audio", audio:{data, format}}. Gemini accepts + // audio as inlineData with an `audio/` mime type (wav, mp3, ...). + // Without this branch the part matches no handler below and is silently + // dropped on the OpenAI→Gemini/Antigravity path. + if (rec.type === "input_audio" || rec.type === "audio") { + const audioObj = toRecord(rec.input_audio || rec.audio); + if (typeof audioObj.data === "string") { + const fmt = String(audioObj.format || "wav").toLowerCase(); + parts.push({ + inlineData: { + mimeType: `audio/${fmt}`, + data: audioObj.data.replace(/^data:[a-zA-Z0-9/+-]+;base64,/, ""), + }, + }); + continue; + } + } + // 1. Handle Gemini native inline_data injected into OpenAI arrays (e.g. Cherry Studio) const geminiInline = toRecord(rec.inline_data || rec.inlineData); if (geminiInline?.data) { diff --git a/tests/unit/gemini-helper-audio-input-912.test.ts b/tests/unit/gemini-helper-audio-input-912.test.ts new file mode 100644 index 0000000000..d62aa60028 --- /dev/null +++ b/tests/unit/gemini-helper-audio-input-912.test.ts @@ -0,0 +1,41 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Regression for the audio-input drop on the OpenAI -> Gemini/Antigravity path. +// Chat Completions clients send `{ type: "input_audio", input_audio: { data, format } }`; +// Gemini accepts audio as `inlineData` with an `audio/` mime type. Before the +// fix, `convertOpenAIContentToParts` had no audio branch, so the part fell through every +// handler and was silently dropped (no error, just missing audio). +const gemini = await import("../../open-sse/translator/helpers/geminiHelper.ts"); + +test("convertOpenAIContentToParts maps input_audio (wav) to Gemini inlineData", () => { + const parts = gemini.convertOpenAIContentToParts([ + { type: "text", text: "transcribe this" }, + { type: "input_audio", input_audio: { data: "QUJDRA==", format: "wav" } }, + ]); + assert.deepEqual(parts, [ + { text: "transcribe this" }, + { inlineData: { mimeType: "audio/wav", data: "QUJDRA==" } }, + ]); +}); + +test("convertOpenAIContentToParts maps input_audio (mp3) and strips a data: prefix", () => { + const parts = gemini.convertOpenAIContentToParts([ + { type: "input_audio", input_audio: { data: "data:audio/mp3;base64,QUJDRA==", format: "mp3" } }, + ]); + assert.deepEqual(parts, [{ inlineData: { mimeType: "audio/mp3", data: "QUJDRA==" } }]); +}); + +test("convertOpenAIContentToParts supports the { type: 'audio', audio: {...} } shape", () => { + const parts = gemini.convertOpenAIContentToParts([ + { type: "audio", audio: { data: "QUJDRA==", format: "wav" } }, + ]); + assert.deepEqual(parts, [{ inlineData: { mimeType: "audio/wav", data: "QUJDRA==" } }]); +}); + +test("convertOpenAIContentToParts defaults the audio mime type to audio/wav", () => { + const parts = gemini.convertOpenAIContentToParts([ + { type: "input_audio", input_audio: { data: "QUJDRA==" } }, + ]); + assert.deepEqual(parts, [{ inlineData: { mimeType: "audio/wav", data: "QUJDRA==" } }]); +});