fix(translator): forward OpenAI audio input to Gemini/Antigravity (port from 9router#912) (#4426)

Integrated into release/v3.8.32
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-20 20:27:04 -03:00
committed by GitHub
parent 3025a2aa0a
commit 0d673f7c9e
3 changed files with 62 additions and 0 deletions

View File

@@ -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/<format>` 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)

View File

@@ -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/<format>` 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) {

View File

@@ -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/<format>` 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==" } }]);
});