From dae69a3916f096909c2118bfd14abd9f714688dc Mon Sep 17 00:00:00 2001 From: nguyenha935 <208228297+nguyenha935@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:48:55 +0700 Subject: [PATCH] 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 --- next.config.mjs | 8 + open-sse/config/runtimeConfig.js | 3 + src/app/api/v1beta/models/[...path]/route.js | 252 ++++++++++++++++++ src/app/api/v1beta/models/route.js | 30 ++- src/dashboardGuard.js | 6 +- tests/unit/dashboard-guard.test.js | 34 ++- tests/unit/gemini-native-endpoint.test.js | 260 +++++++++++++++++++ 7 files changed, 585 insertions(+), 8 deletions(-) create mode 100644 tests/unit/gemini-native-endpoint.test.js diff --git a/next.config.mjs b/next.config.mjs index 468f4f50..91f55af9 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -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*" diff --git a/open-sse/config/runtimeConfig.js b/open-sse/config/runtimeConfig.js index aeacd2db..de199233 100644 --- a/open-sse/config/runtimeConfig.js +++ b/open-sse/config/runtimeConfig.js @@ -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; diff --git a/src/app/api/v1beta/models/[...path]/route.js b/src/app/api/v1beta/models/[...path]/route.js index aef74b8a..fd1d1cb8 100644 --- a/src/app/api/v1beta/models/[...path]/route.js +++ b/src/app/api/v1beta/models/[...path]/route.js @@ -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. * diff --git a/src/app/api/v1beta/models/route.js b/src/app/api/v1beta/models/route.js index 806ffa54..b5b47a98 100644 --- a/src/app/api/v1beta/models/route.js +++ b/src/app/api/v1beta/models/route.js @@ -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 }); } } - diff --git a/src/dashboardGuard.js b/src/dashboardGuard.js index 6d86cc78..83e4364e 100644 --- a/src/dashboardGuard.js +++ b/src/dashboardGuard.js @@ -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) { diff --git a/tests/unit/dashboard-guard.test.js b/tests/unit/dashboard-guard.test.js index 99cdd918..099cbde7 100644 --- a/tests/unit/dashboard-guard.test.js +++ b/tests/unit/dashboard-guard.test.js @@ -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"); + }); }); diff --git a/tests/unit/gemini-native-endpoint.test.js b/tests/unit/gemini-native-endpoint.test.js new file mode 100644 index 00000000..83a8716c --- /dev/null +++ b/tests/unit/gemini-native-endpoint.test.js @@ -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(); + }); +});