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:
@@ -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*"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
260
tests/unit/gemini-native-endpoint.test.js
Normal file
260
tests/unit/gemini-native-endpoint.test.js
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user