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:50:13 +07:00
committed by decolua
co-authored by Cursor
parent 1980178d02
commit dae69a3916
7 changed files with 585 additions and 8 deletions
@@ -1,7 +1,17 @@
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";
let initialized = false;
const GEMINI_NATIVE_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/models";
/**
* Initialize translators once
@@ -72,6 +82,10 @@ export async function POST(request, { params }) {
const body = await request.json();
if (isGeminiNativeTtsRequest(model, body)) {
return await forwardGeminiNativeRequest(request, body, model, action);
}
// Streaming is determined by URL action suffix:
// :streamGenerateContent => stream: true (SSE)
// :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.
*
+24 -6
View File
@@ -19,19 +19,38 @@ export async function OPTIONS() {
*/
export async function GET() {
try {
// Collect all models from all providers
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 model of providerModels) {
models.push({
addModel({
name: `models/${provider}/${model.id}`,
displayName: 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 });
}
}
+5 -1
View File
@@ -119,7 +119,11 @@ function isPublicLlmApi(pathname) {
function extractApiKey(request) {
const authHeader = request.headers.get("Authorization");
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) {