fix(gemini): support native TTS generateContent endpoint

Pass Gemini AUDIO/TTS generateContent requests through to Google's native
v1beta endpoint instead of converting to chat, with per-credential fallback
(504 timeout, 502 fetch failure). Accept client keys from Bearer,
x-goog-api-key, or ?key= while forwarding only the configured Gemini
credential upstream. Expose native v1beta model names and rewrites, and add
Gemini 3.1 Flash TTS to the catalogs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
nguyenha935
2026-06-26 10:48:55 +07:00
committed by decolua
parent 1980178d02
commit dae69a3916
7 changed files with 585 additions and 8 deletions

View File

@@ -66,6 +66,14 @@ const nextConfig = {
source: "/responses", source: "/responses",
destination: "/api/v1/responses" destination: "/api/v1/responses"
}, },
{
source: "/v1beta/:path*",
destination: "/api/v1beta/:path*"
},
{
source: "/v1beta",
destination: "/api/v1beta"
},
{ {
source: "/v1/:path*", source: "/v1/:path*",
destination: "/api/v1/:path*" destination: "/api/v1/:path*"

View File

@@ -49,6 +49,9 @@ export const STREAM_FIRST_CHUNK_TIMEOUT_MS = envMs("STREAM_FIRST_CHUNK_TIMEOUT_M
// Fetch connect timeout: abort if upstream doesn't return response headers within this duration // Fetch connect timeout: abort if upstream doesn't return response headers within this duration
export const FETCH_CONNECT_TIMEOUT_MS = envMs("FETCH_CONNECT_TIMEOUT_MS", 60 * 1000); export const FETCH_CONNECT_TIMEOUT_MS = envMs("FETCH_CONNECT_TIMEOUT_MS", 60 * 1000);
// Gemini native TTS fetch timeout: abort if Google does not return response headers in time.
export const GEMINI_NATIVE_TTS_FETCH_TIMEOUT_MS = envMs("GEMINI_NATIVE_TTS_FETCH_TIMEOUT_MS", 45 * 1000);
// Default token limits // Default token limits
export const DEFAULT_MAX_TOKENS = 64000; export const DEFAULT_MAX_TOKENS = 64000;
export const DEFAULT_MIN_TOKENS = 32000; export const DEFAULT_MIN_TOKENS = 32000;

View File

@@ -1,7 +1,17 @@
import { handleChat } from "@/sse/handlers/chat.js"; import { handleChat } from "@/sse/handlers/chat.js";
import {
clearAccountError,
getProviderCredentials,
isValidApiKey,
markAccountUnavailable,
} from "@/sse/services/auth.js";
import { getSettings } from "@/lib/localDb";
import { PROVIDER_MODELS } from "@/shared/constants/models";
import { GEMINI_NATIVE_TTS_FETCH_TIMEOUT_MS } from "open-sse/config/runtimeConfig.js";
import { initTranslators } from "open-sse/translator/index.js"; import { initTranslators } from "open-sse/translator/index.js";
let initialized = false; let initialized = false;
const GEMINI_NATIVE_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/models";
/** /**
* Initialize translators once * Initialize translators once
@@ -72,6 +82,10 @@ export async function POST(request, { params }) {
const body = await request.json(); const body = await request.json();
if (isGeminiNativeTtsRequest(model, body)) {
return await forwardGeminiNativeRequest(request, body, model, action);
}
// Streaming is determined by URL action suffix: // Streaming is determined by URL action suffix:
// :streamGenerateContent => stream: true (SSE) // :streamGenerateContent => stream: true (SSE)
// :generateContent => stream: false (plain JSON) // :generateContent => stream: false (plain JSON)
@@ -107,6 +121,244 @@ export async function POST(request, { params }) {
} }
} }
function extractGeminiClientApiKey(request) {
const authHeader = request.headers.get("Authorization");
if (authHeader?.startsWith("Bearer ")) return authHeader.slice(7);
const googleApiKey = request.headers.get("x-goog-api-key");
if (googleApiKey) return googleApiKey;
const url = new URL(request.url);
return url.searchParams.get("key");
}
function normalizeGeminiNativeModel(model) {
return String(model || "")
.replace(/^models\//, "")
.replace(/^gemini\//, "");
}
function getGeminiTtsModelIds() {
return new Set([
...(PROVIDER_MODELS.gemini || [])
.filter((model) => (model.kind || model.type) === "tts")
.map((model) => model.id),
...(PROVIDER_MODELS["gemini-tts-models"] || []).map((model) => model.id),
]);
}
function hasAudioResponseModality(body) {
const modalities = body?.generationConfig?.responseModalities;
return Array.isArray(modalities)
&& modalities.some((modality) => String(modality).toUpperCase() === "AUDIO");
}
function isGeminiNativeTtsRequest(model, body) {
const rawModel = String(model || "");
if (rawModel.includes("/") && !rawModel.startsWith("gemini/") && !rawModel.startsWith("models/")) {
return false;
}
const modelId = normalizeGeminiNativeModel(model);
return hasAudioResponseModality(body) || getGeminiTtsModelIds().has(modelId);
}
function buildGeminiNativeUrl(requestUrl, model, action) {
const sourceUrl = new URL(requestUrl);
const upstreamUrl = new URL(`${GEMINI_NATIVE_BASE_URL}/${normalizeGeminiNativeModel(model)}${action}`);
for (const [key, value] of sourceUrl.searchParams.entries()) {
if (key === "key") continue;
upstreamUrl.searchParams.append(key, value);
}
return upstreamUrl.toString();
}
async function validateGeminiNativeClientKey(request) {
const settings = await getSettings();
if (!settings.requireApiKey) return null;
const apiKey = extractGeminiClientApiKey(request);
if (!apiKey) {
return Response.json({ error: { message: "Missing API key" } }, { status: 401 });
}
const valid = await isValidApiKey(apiKey);
if (!valid) {
return Response.json({ error: { message: "Invalid API key" } }, { status: 401 });
}
return null;
}
function buildGeminiNativeAuthHeaders(credentials) {
if (credentials?.apiKey) return { "x-goog-api-key": credentials.apiKey };
if (credentials?.accessToken) return { Authorization: `Bearer ${credentials.accessToken}` };
return null;
}
function corsHeadersFrom(response) {
const headers = new Headers(response.headers);
// Node fetch may expose a decoded body while preserving upstream compression
// headers. Forwarding those headers makes clients decompress plain bytes again.
headers.delete("content-encoding");
headers.delete("content-length");
headers.delete("transfer-encoding");
headers.set("Access-Control-Allow-Origin", "*");
return headers;
}
function getSafeGeminiConnectionLabel(credentials) {
const connectionId = String(credentials?.connectionId || "unknown");
const shortId = connectionId.slice(0, 8);
const connectionName = String(credentials?.connectionName || "");
if (!connectionName || connectionName.includes("@")) return shortId;
return `${connectionName}:${shortId}`;
}
function getGeminiNativeErrorCode(error) {
return error?.cause?.code || error?.code || error?.cause?.name || error?.name || "UNKNOWN";
}
function isGeminiNativeTimeoutError(error, timedOut) {
if (timedOut) return true;
const code = getGeminiNativeErrorCode(error);
return code === "UND_ERR_HEADERS_TIMEOUT" || code === "HeadersTimeoutError";
}
function getSafeGeminiNativeErrorText(error) {
const message = error?.message || String(error);
const code = getGeminiNativeErrorCode(error);
return `${message} (${code})`;
}
async function forwardGeminiNativeRequest(request, body, model, action) {
const authError = await validateGeminiNativeClientKey(request);
if (authError) return authError;
const modelId = normalizeGeminiNativeModel(model);
const excludeConnectionIds = new Set();
const bodyText = JSON.stringify(body);
let lastError = null;
let lastStatus = null;
while (true) {
const credentials = await getProviderCredentials("gemini", excludeConnectionIds, modelId);
if (!credentials || credentials.allRateLimited) {
console.log(`[GEMINI_NATIVE] exhausted model=${modelId} status=${lastStatus || Number(credentials?.lastErrorCode) || 503} error=${lastError || credentials?.lastError || "No active credentials for provider: gemini"}`);
return Response.json(
{ error: { message: lastError || credentials?.lastError || "No active credentials for provider: gemini" } },
{ status: lastStatus || Number(credentials?.lastErrorCode) || 503 }
);
}
const authHeaders = buildGeminiNativeAuthHeaders(credentials);
if (!authHeaders) {
return Response.json(
{ error: { message: "No Gemini API key configured" } },
{ status: 404 }
);
}
const safeConnection = getSafeGeminiConnectionLabel(credentials);
const startedAt = Date.now();
const upstreamUrl = buildGeminiNativeUrl(request.url, modelId, action);
const attemptController = new AbortController();
let timedOut = false;
const timeout = setTimeout(() => {
timedOut = true;
attemptController.abort();
}, GEMINI_NATIVE_TTS_FETCH_TIMEOUT_MS);
const abortAttempt = () => attemptController.abort();
if (request.signal?.aborted) {
console.log(`[GEMINI_NATIVE] client aborted model=${modelId} ms=0 conn=${safeConnection}`);
return Response.json({ error: { message: "Client closed request" } }, { status: 499 });
}
request.signal?.addEventListener("abort", abortAttempt, { once: true });
console.log(`[GEMINI_NATIVE] start model=${modelId} action=${action} conn=${safeConnection} body=${Buffer.byteLength(bodyText)}B timeout=${GEMINI_NATIVE_TTS_FETCH_TIMEOUT_MS}`);
let upstreamResponse;
try {
upstreamResponse = await fetch(upstreamUrl, {
method: "POST",
headers: {
"Content-Type": request.headers.get("Content-Type") || "application/json",
...authHeaders,
},
body: bodyText,
signal: attemptController.signal,
});
} catch (error) {
const durationMs = Date.now() - startedAt;
if (request.signal?.aborted && !timedOut) {
console.log(`[GEMINI_NATIVE] client aborted model=${modelId} ms=${durationMs} conn=${safeConnection}`);
return Response.json({ error: { message: "Client closed request" } }, { status: 499 });
}
const status = isGeminiNativeTimeoutError(error, timedOut) ? 504 : 502;
const errorText = getSafeGeminiNativeErrorText(error);
console.log(`[GEMINI_NATIVE] fetch failed model=${modelId} status=${status} ms=${durationMs} conn=${safeConnection} error=${errorText}`);
const { shouldFallback } = await markAccountUnavailable(
credentials.connectionId,
status,
errorText,
"gemini",
modelId
);
if (shouldFallback) {
excludeConnectionIds.add(credentials.connectionId);
lastError = errorText;
lastStatus = status;
console.log(`[GEMINI_NATIVE] fallback model=${modelId} status=${status} conn=${safeConnection} exclude=${excludeConnectionIds.size}`);
continue;
}
return Response.json({ error: { message: errorText } }, { status });
} finally {
clearTimeout(timeout);
request.signal?.removeEventListener("abort", abortAttempt);
}
console.log(`[GEMINI_NATIVE] upstream model=${modelId} status=${upstreamResponse.status} ms=${Date.now() - startedAt} conn=${safeConnection} ct=${upstreamResponse.headers.get("content-type") || "?"} cl=${upstreamResponse.headers.get("content-length") || "?"}`);
if (upstreamResponse.ok) {
await clearAccountError(credentials.connectionId, credentials, modelId);
return new Response(upstreamResponse.body, {
status: upstreamResponse.status,
statusText: upstreamResponse.statusText,
headers: corsHeadersFrom(upstreamResponse),
});
}
const errorText = await upstreamResponse.text();
const { shouldFallback } = await markAccountUnavailable(
credentials.connectionId,
upstreamResponse.status,
errorText,
"gemini",
modelId
);
if (shouldFallback) {
excludeConnectionIds.add(credentials.connectionId);
lastError = errorText;
lastStatus = upstreamResponse.status;
continue;
}
return new Response(errorText, {
status: upstreamResponse.status,
statusText: upstreamResponse.statusText,
headers: corsHeadersFrom(upstreamResponse),
});
}
}
/** /**
* Convert Gemini request format to OpenAI/internal format. * Convert Gemini request format to OpenAI/internal format.
* *

View File

@@ -19,19 +19,38 @@ export async function OPTIONS() {
*/ */
export async function GET() { export async function GET() {
try { try {
// Collect all models from all providers
const models = []; const models = [];
const seen = new Set();
function addModel({ name, displayName, description, methods = ["generateContent"] }) {
if (seen.has(name)) return;
seen.add(name);
models.push({
name,
displayName,
description,
supportedGenerationMethods: methods,
inputTokenLimit: 128000,
outputTokenLimit: 8192,
});
}
for (const [provider, providerModels] of Object.entries(PROVIDER_MODELS)) { for (const [provider, providerModels] of Object.entries(PROVIDER_MODELS)) {
for (const model of providerModels) { for (const model of providerModels) {
models.push({ addModel({
name: `models/${provider}/${model.id}`, name: `models/${provider}/${model.id}`,
displayName: model.name || model.id, displayName: model.name || model.id,
description: `${provider} model: ${model.name || model.id}`, description: `${provider} model: ${model.name || model.id}`,
supportedGenerationMethods: ["generateContent"],
inputTokenLimit: 128000,
outputTokenLimit: 8192,
}); });
if (provider === "gemini") {
addModel({
name: `models/${model.id}`,
displayName: model.name || model.id,
description: `Gemini model: ${model.name || model.id}`,
methods: ["generateContent", "streamGenerateContent"],
});
}
} }
} }
@@ -41,4 +60,3 @@ export async function GET() {
return Response.json({ error: { message: error.message } }, { status: 500 }); return Response.json({ error: { message: error.message } }, { status: 500 });
} }
} }

View File

@@ -119,7 +119,11 @@ function isPublicLlmApi(pathname) {
function extractApiKey(request) { function extractApiKey(request) {
const authHeader = request.headers.get("Authorization"); const authHeader = request.headers.get("Authorization");
if (authHeader?.startsWith("Bearer ")) return authHeader.slice(7); if (authHeader?.startsWith("Bearer ")) return authHeader.slice(7);
return request.headers.get("x-api-key"); const apiKeyHeader = request.headers.get("x-api-key");
if (apiKeyHeader) return apiKeyHeader;
const googleApiKeyHeader = request.headers.get("x-goog-api-key");
if (googleApiKeyHeader) return googleApiKeyHeader;
return request.nextUrl.searchParams?.get("key") || null;
} }
async function hasValidApiKey(request) { async function hasValidApiKey(request) {

View File

@@ -38,7 +38,7 @@ const { proxy, __test__ } = await import("../../src/dashboardGuard.js");
function request(pathname, headers = {}) { function request(pathname, headers = {}) {
const normalizedHeaders = new Headers(headers); const normalizedHeaders = new Headers(headers);
return { return {
nextUrl: { pathname }, nextUrl: { pathname, searchParams: new URL(`http://localhost${pathname}`).searchParams },
headers: normalizedHeaders, headers: normalizedHeaders,
cookies: { get: vi.fn(() => undefined) }, cookies: { get: vi.fn(() => undefined) },
url: `http://localhost${pathname}`, url: `http://localhost${pathname}`,
@@ -163,6 +163,29 @@ describe("dashboard guard public LLM API access", () => {
expect(response).toBe(mocks.nextResponse); expect(response).toBe(mocks.nextResponse);
expect(mocks.validateApiKey).toHaveBeenCalledWith("sk-valid"); expect(mocks.validateApiKey).toHaveBeenCalledWith("sk-valid");
}); });
it("allows remote beta public LLM API with valid Google API key header", async () => {
mocks.validateApiKey.mockResolvedValue(true);
const response = await proxy(request("/v1beta/models", {
host: "router.example.com",
"x-goog-api-key": "sk-valid",
}));
expect(response).toBe(mocks.nextResponse);
expect(mocks.validateApiKey).toHaveBeenCalledWith("sk-valid");
});
it("allows remote beta public LLM API with valid Google key query parameter", async () => {
mocks.validateApiKey.mockResolvedValue(true);
const response = await proxy(request("/v1beta/models?key=sk-valid", {
host: "router.example.com",
}));
expect(response).toBe(mocks.nextResponse);
expect(mocks.validateApiKey).toHaveBeenCalledWith("sk-valid");
});
}); });
describe("dashboard guard local-only access", () => { describe("dashboard guard local-only access", () => {
@@ -244,4 +267,13 @@ describe("dashboard guard helpers", () => {
expect(__test__.extractApiKey(apiRequest)).toBe("bearer-key"); expect(__test__.extractApiKey(apiRequest)).toBe("bearer-key");
}); });
it("extracts Google API keys after x-api-key", () => {
const apiRequest = request("/v1beta/models?key=query-key", {
"x-api-key": "header-key",
"x-goog-api-key": "google-key",
});
expect(__test__.extractApiKey(apiRequest)).toBe("header-key");
});
}); });

View File

@@ -0,0 +1,260 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
handleChat: vi.fn(),
getSettings: vi.fn(),
isValidApiKey: vi.fn(),
getProviderCredentials: vi.fn(),
markAccountUnavailable: vi.fn(),
clearAccountError: vi.fn(),
}));
vi.mock("@/sse/handlers/chat.js", () => ({
handleChat: mocks.handleChat,
}));
vi.mock("@/sse/services/auth.js", () => ({
getProviderCredentials: mocks.getProviderCredentials,
isValidApiKey: mocks.isValidApiKey,
markAccountUnavailable: mocks.markAccountUnavailable,
clearAccountError: mocks.clearAccountError,
}));
vi.mock("@/lib/localDb", () => ({
getSettings: mocks.getSettings,
}));
const { GET } = await import("../../src/app/api/v1beta/models/route.js");
const { POST } = await import("../../src/app/api/v1beta/models/[...path]/route.js");
function makeGeminiRequest(path, body, headers = {}, signal) {
return new Request(`https://router.test/v1beta/models/${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer router-client-key",
...headers,
},
body: JSON.stringify(body),
signal,
});
}
function audioBody() {
return {
contents: [{ parts: [{ text: "Speak naturally: hello" }] }],
generationConfig: {
responseModalities: ["AUDIO"],
speechConfig: {
voiceConfig: {
prebuiltVoiceConfig: { voiceName: "Kore" },
},
},
temperature: 0.01,
seed: 123,
},
};
}
describe("Gemini native v1beta endpoint", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getSettings.mockResolvedValue({ requireApiKey: true });
mocks.isValidApiKey.mockResolvedValue(true);
mocks.getProviderCredentials.mockResolvedValue({
apiKey: "real-gemini-key",
connectionId: "gemini-conn",
connectionName: "Gemini Test",
providerSpecificData: {},
});
mocks.markAccountUnavailable.mockResolvedValue({ shouldFallback: false });
global.fetch = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ candidates: [{ content: { parts: [{ text: "ok" }] } }] }), {
status: 200,
headers: { "Content-Type": "application/json" },
})
);
mocks.handleChat.mockResolvedValue(
Response.json({ candidates: [{ content: { parts: [{ text: "chat" }] } }] })
);
});
it("lists Gemini TTS models using standard Google model names", async () => {
const response = await GET();
const body = await response.json();
const names = body.models.map((model) => model.name);
expect(names).toContain("models/gemini-3.1-flash-tts-preview");
expect(names).toContain("models/gemini-2.5-flash-preview-tts");
expect(names).toContain("models/gemini-2.5-pro-preview-tts");
});
it("passes Gemini AUDIO generateContent requests through to Google's native endpoint", async () => {
const body = audioBody();
const response = await POST(makeGeminiRequest("gemini-3.1-flash-tts-preview:generateContent", body), {
params: Promise.resolve({ path: ["gemini-3.1-flash-tts-preview:generateContent"] }),
});
expect(response.status).toBe(200);
expect(mocks.handleChat).not.toHaveBeenCalled();
expect(global.fetch).toHaveBeenCalledTimes(1);
expect(global.fetch.mock.calls[0][0]).toBe(
"https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-tts-preview:generateContent"
);
const options = global.fetch.mock.calls[0][1];
expect(options.method).toBe("POST");
expect(JSON.parse(options.body)).toEqual(body);
expect(options.headers["x-goog-api-key"]).toBe("real-gemini-key");
expect(options.headers.Authorization).toBeUndefined();
});
it("accepts Google-style client keys without forwarding them upstream", async () => {
const request = makeGeminiRequest(
"gemini-2.5-flash-preview-tts:generateContent?key=query-router-key",
audioBody(),
{
Authorization: "",
"x-goog-api-key": "client-router-key",
}
);
await POST(request, {
params: Promise.resolve({ path: ["gemini-2.5-flash-preview-tts:generateContent"] }),
});
expect(mocks.isValidApiKey).toHaveBeenCalledWith("client-router-key");
expect(global.fetch.mock.calls[0][1].headers["x-goog-api-key"]).toBe("real-gemini-key");
expect(global.fetch.mock.calls[0][1].headers["x-goog-api-key"]).not.toBe("client-router-key");
});
it("does not forward stale compression headers from native upstream responses", async () => {
global.fetch.mockResolvedValueOnce(
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: {
"Content-Type": "application/json",
"Content-Encoding": "gzip",
"Content-Length": "123",
},
})
);
const response = await POST(makeGeminiRequest("gemini-3.1-flash-tts-preview:generateContent", audioBody()), {
params: Promise.resolve({ path: ["gemini-3.1-flash-tts-preview:generateContent"] }),
});
expect(response.status).toBe(200);
expect(response.headers.get("content-encoding")).toBeNull();
expect(response.headers.get("content-length")).toBeNull();
});
it("falls back to the next Gemini credential when native fetch times out before headers", async () => {
const timeoutError = new TypeError("fetch failed");
timeoutError.cause = { code: "UND_ERR_HEADERS_TIMEOUT", name: "HeadersTimeoutError" };
mocks.getProviderCredentials
.mockResolvedValueOnce({
apiKey: "first-gemini-key",
connectionId: "first-conn",
connectionName: "First Gemini",
providerSpecificData: {},
})
.mockResolvedValueOnce({
apiKey: "second-gemini-key",
connectionId: "second-conn",
connectionName: "Second Gemini",
providerSpecificData: {},
});
mocks.markAccountUnavailable.mockResolvedValueOnce({ shouldFallback: true });
global.fetch
.mockRejectedValueOnce(timeoutError)
.mockResolvedValueOnce(
new Response(JSON.stringify({ candidates: [{ content: { parts: [{ inlineData: { data: "pcm" } }] } }] }), {
status: 200,
headers: { "Content-Type": "application/json" },
})
);
const response = await POST(makeGeminiRequest("gemini-3.1-flash-tts-preview:generateContent", audioBody()), {
params: Promise.resolve({ path: ["gemini-3.1-flash-tts-preview:generateContent"] }),
});
expect(response.status).toBe(200);
expect(global.fetch).toHaveBeenCalledTimes(2);
expect(global.fetch.mock.calls[0][1].headers["x-goog-api-key"]).toBe("first-gemini-key");
expect(global.fetch.mock.calls[1][1].headers["x-goog-api-key"]).toBe("second-gemini-key");
expect(mocks.markAccountUnavailable).toHaveBeenCalledWith(
"first-conn",
504,
expect.stringContaining("UND_ERR_HEADERS_TIMEOUT"),
"gemini",
"gemini-3.1-flash-tts-preview"
);
expect(mocks.clearAccountError).toHaveBeenCalledWith(
"second-conn",
expect.objectContaining({ apiKey: "second-gemini-key" }),
"gemini-3.1-flash-tts-preview"
);
});
it("returns 502 for native fetch failures when credential fallback is not allowed", async () => {
const networkError = new TypeError("fetch failed");
networkError.cause = { code: "ECONNRESET" };
mocks.markAccountUnavailable.mockResolvedValueOnce({ shouldFallback: false });
global.fetch.mockRejectedValueOnce(networkError);
const response = await POST(makeGeminiRequest("gemini-3.1-flash-tts-preview:generateContent", audioBody()), {
params: Promise.resolve({ path: ["gemini-3.1-flash-tts-preview:generateContent"] }),
});
const body = await response.json();
expect(response.status).toBe(502);
expect(body.error.message).toContain("ECONNRESET");
expect(mocks.markAccountUnavailable).toHaveBeenCalledWith(
"gemini-conn",
502,
expect.stringContaining("ECONNRESET"),
"gemini",
"gemini-3.1-flash-tts-preview"
);
});
it("does not mark Gemini credentials unavailable when the native client aborts", async () => {
const controller = new AbortController();
controller.abort();
global.fetch.mockRejectedValueOnce(new DOMException("The operation was aborted", "AbortError"));
const response = await POST(
makeGeminiRequest("gemini-3.1-flash-tts-preview:generateContent", audioBody(), {}, controller.signal),
{
params: Promise.resolve({ path: ["gemini-3.1-flash-tts-preview:generateContent"] }),
}
);
expect(response.status).toBe(499);
expect(mocks.markAccountUnavailable).not.toHaveBeenCalled();
});
it("keeps non-audio Gemini requests on the existing chat conversion path", async () => {
const body = {
contents: [{ parts: [{ text: "hello" }] }],
generationConfig: { temperature: 0.3 },
};
await POST(makeGeminiRequest("gemini-2.5-flash:generateContent", body), {
params: Promise.resolve({ path: ["gemini-2.5-flash:generateContent"] }),
});
expect(mocks.handleChat).toHaveBeenCalledTimes(1);
expect(global.fetch).not.toHaveBeenCalled();
});
it("does not hijack provider-prefixed non-Gemini audio requests", async () => {
await POST(makeGeminiRequest("openai/gpt-4o-mini-tts:generateContent", audioBody()), {
params: Promise.resolve({ path: ["openai", "gpt-4o-mini-tts:generateContent"] }),
});
expect(mocks.handleChat).toHaveBeenCalledTimes(1);
expect(global.fetch).not.toHaveBeenCalled();
});
});