mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 20:00:47 +00:00
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:
committed by
decolua
co-authored by
Cursor
parent
1980178d02
commit
dae69a3916
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user