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",
|
||||
destination: "/api/v1/responses"
|
||||
},
|
||||
{
|
||||
source: "/v1beta/:path*",
|
||||
destination: "/api/v1beta/:path*"
|
||||
},
|
||||
{
|
||||
source: "/v1beta",
|
||||
destination: "/api/v1beta"
|
||||
},
|
||||
{
|
||||
source: "/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
|
||||
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
|
||||
export const DEFAULT_MAX_TOKENS = 64000;
|
||||
export const DEFAULT_MIN_TOKENS = 32000;
|
||||
|
||||
@@ -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,20 +19,39 @@ export async function OPTIONS() {
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
// Collect all models from all providers
|
||||
const models = [];
|
||||
const seen = new Set();
|
||||
|
||||
for (const [provider, providerModels] of Object.entries(PROVIDER_MODELS)) {
|
||||
for (const model of providerModels) {
|
||||
function addModel({ name, displayName, description, methods = ["generateContent"] }) {
|
||||
if (seen.has(name)) return;
|
||||
seen.add(name);
|
||||
models.push({
|
||||
name: `models/${provider}/${model.id}`,
|
||||
displayName: model.name || model.id,
|
||||
description: `${provider} model: ${model.name || model.id}`,
|
||||
supportedGenerationMethods: ["generateContent"],
|
||||
name,
|
||||
displayName,
|
||||
description,
|
||||
supportedGenerationMethods: methods,
|
||||
inputTokenLimit: 128000,
|
||||
outputTokenLimit: 8192,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [provider, providerModels] of Object.entries(PROVIDER_MODELS)) {
|
||||
for (const model of providerModels) {
|
||||
addModel({
|
||||
name: `models/${provider}/${model.id}`,
|
||||
displayName: model.name || model.id,
|
||||
description: `${provider} model: ${model.name || model.id}`,
|
||||
});
|
||||
|
||||
if (provider === "gemini") {
|
||||
addModel({
|
||||
name: `models/${model.id}`,
|
||||
displayName: model.name || model.id,
|
||||
description: `Gemini model: ${model.name || model.id}`,
|
||||
methods: ["generateContent", "streamGenerateContent"],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Response.json({ models });
|
||||
@@ -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) {
|
||||
|
||||
@@ -38,7 +38,7 @@ const { proxy, __test__ } = await import("../../src/dashboardGuard.js");
|
||||
function request(pathname, headers = {}) {
|
||||
const normalizedHeaders = new Headers(headers);
|
||||
return {
|
||||
nextUrl: { pathname },
|
||||
nextUrl: { pathname, searchParams: new URL(`http://localhost${pathname}`).searchParams },
|
||||
headers: normalizedHeaders,
|
||||
cookies: { get: vi.fn(() => undefined) },
|
||||
url: `http://localhost${pathname}`,
|
||||
@@ -163,6 +163,29 @@ describe("dashboard guard public LLM API access", () => {
|
||||
expect(response).toBe(mocks.nextResponse);
|
||||
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", () => {
|
||||
@@ -244,4 +267,13 @@ describe("dashboard guard helpers", () => {
|
||||
|
||||
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