Spaces:
Sleeping
Sleeping
| 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"; | |
| // Gemini model id charset (matches sanitizeGeminiFunctionName); blocks path traversal in upstream URL. | |
| const GEMINI_NATIVE_MODEL_PATTERN = /^[a-zA-Z0-9_.:-]+$/; | |
| /** | |
| * Initialize translators once | |
| */ | |
| async function ensureInitialized() { | |
| if (!initialized) { | |
| await initTranslators(); | |
| initialized = true; | |
| } | |
| } | |
| /** | |
| * Handle CORS preflight | |
| */ | |
| export async function OPTIONS() { | |
| return new Response(null, { | |
| headers: { | |
| "Access-Control-Allow-Origin": "*", | |
| "Access-Control-Allow-Methods": "GET, POST, OPTIONS", | |
| "Access-Control-Allow-Headers": "*" | |
| } | |
| }); | |
| } | |
| /** | |
| * POST /v1beta/models/{model}:generateContent — non-streaming | |
| * POST /v1beta/models/{model}:streamGenerateContent — streaming (SSE) | |
| * | |
| * Streaming intent is determined by the URL action suffix (canonical Gemini API | |
| * convention), NOT by a body field. generationConfig.stream is not a real | |
| * Gemini API field and Gemini CLI never sets it. | |
| * | |
| * The @google/genai SDK always uses :streamGenerateContent?alt=sse for chat. | |
| * The upstream handleChat returns OpenAI SSE format; we transform it to | |
| * Gemini SSE format on the fly via transformOpenAISSEToGeminiSSE(). | |
| */ | |
| export async function POST(request, { params }) { | |
| await ensureInitialized(); | |
| try { | |
| const { path } = await params; | |
| // path = ["provider", "model:action"] or ["model:action"] | |
| let model; | |
| let action; // ":generateContent" | ":streamGenerateContent" | |
| if (path.length >= 2) { | |
| // Format: /v1beta/models/provider/model:generateContent | |
| const provider = path[0]; | |
| const modelAction = path[1]; | |
| action = modelAction.includes(":streamGenerateContent") | |
| ? ":streamGenerateContent" | |
| : ":generateContent"; | |
| const modelName = modelAction | |
| .replace(":streamGenerateContent", "") | |
| .replace(":generateContent", ""); | |
| model = provider + "/" + modelName; | |
| } else { | |
| // Format: /v1beta/models/model:generateContent | |
| const modelAction = path[0]; | |
| action = modelAction.includes(":streamGenerateContent") | |
| ? ":streamGenerateContent" | |
| : ":generateContent"; | |
| model = modelAction | |
| .replace(":streamGenerateContent", "") | |
| .replace(":generateContent", ""); | |
| } | |
| 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) | |
| const stream = action === ":streamGenerateContent"; | |
| // Convert Gemini request format to OpenAI/internal format | |
| const convertedBody = convertGeminiToInternal(body, model, stream); | |
| // Create new request with converted body | |
| const newRequest = new Request(request.url, { | |
| method: "POST", | |
| headers: request.headers, | |
| body: JSON.stringify(convertedBody), | |
| }); | |
| const response = await handleChat(newRequest); | |
| if (stream) { | |
| // Transform OpenAI SSE => Gemini SSE on the fly. | |
| // The @google/genai SDK always uses :streamGenerateContent?alt=sse and | |
| // expects Gemini SSE chunks (no [DONE] sentinel — stream just closes). | |
| return transformOpenAISSEToGeminiSSE(response, model); | |
| } else { | |
| // Convert OpenAI JSON response => Gemini GenerateContentResponse | |
| return await convertOpenAIResponseToGemini(response, model); | |
| } | |
| } catch (error) { | |
| console.log("Error handling Gemini request:", error); | |
| return Response.json( | |
| { error: { message: error.message, code: 500 } }, | |
| { status: 500 } | |
| ); | |
| } | |
| } | |
| 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); | |
| if (!GEMINI_NATIVE_MODEL_PATTERN.test(modelId)) { | |
| return Response.json({ error: { message: "Invalid model" } }, { status: 400 }); | |
| } | |
| 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. | |
| * | |
| * @param {object} geminiBody - parsed Gemini request body | |
| * @param {string} model - resolved model string (e.g. "gemini-pro-high") | |
| * @param {boolean} stream - whether to stream (from URL action) | |
| */ | |
| function convertGeminiToInternal(geminiBody, model, stream) { | |
| const messages = []; | |
| // Convert system instruction | |
| if (geminiBody.systemInstruction) { | |
| const systemText = geminiBody.systemInstruction.parts | |
| ?.map(p => p.text) | |
| .join("\n") || ""; | |
| if (systemText) { | |
| messages.push({ role: "system", content: systemText }); | |
| } | |
| } | |
| // Convert contents to messages | |
| if (geminiBody.contents) { | |
| for (const content of geminiBody.contents) { | |
| const role = content.role === "model" ? "assistant" : "user"; | |
| const text = content.parts?.map(p => p.text).join("\n") || ""; | |
| messages.push({ role, content: text }); | |
| } | |
| } | |
| return { | |
| model, | |
| messages, | |
| stream, | |
| max_tokens: geminiBody.generationConfig?.maxOutputTokens, | |
| temperature: geminiBody.generationConfig?.temperature, | |
| top_p: geminiBody.generationConfig?.topP, | |
| }; | |
| } | |
| /** Map OpenAI finish_reason => Gemini finishReason */ | |
| const FINISH_REASON_MAP = { | |
| stop: "STOP", | |
| length: "MAX_TOKENS", | |
| tool_calls: "STOP", | |
| content_filter: "SAFETY", | |
| }; | |
| /** | |
| * Transform an OpenAI SSE stream into a Gemini SSE stream. | |
| * | |
| * OpenAI SSE format (what handleChat returns): | |
| * data: {"choices":[{"delta":{"content":"Hi"},"finish_reason":null}]} | |
| * data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{...}} | |
| * data: [DONE] | |
| * | |
| * Gemini SSE format (what @google/genai SDK expects): | |
| * data: {"candidates":[{"content":{"role":"model","parts":[{"text":"Hi"}]},"index":0}]} | |
| * data: {"candidates":[{"content":{"role":"model","parts":[{"text":""}]},"finishReason":"STOP","index":0}],"usageMetadata":{...}} | |
| * (stream closes — no [DONE]) | |
| */ | |
| function transformOpenAISSEToGeminiSSE(upstreamResponse, model) { | |
| if (!upstreamResponse.ok || !upstreamResponse.body) { | |
| return upstreamResponse; | |
| } | |
| const decoder = new TextDecoder(); | |
| const encoder = new TextEncoder(); | |
| const transformStream = new TransformStream({ | |
| transform(chunk, controller) { | |
| const text = decoder.decode(chunk, { stream: true }); | |
| const lines = text.split("\n"); | |
| for (const line of lines) { | |
| if (!line.startsWith("data:")) continue; | |
| const data = line.slice(5).trim(); | |
| // Drop empty lines and the OpenAI [DONE] sentinel. | |
| // Gemini SSE ends by stream close, no sentinel needed. | |
| if (!data || data === "[DONE]") continue; | |
| let parsed; | |
| try { | |
| parsed = JSON.parse(data); | |
| } catch { | |
| continue; | |
| } | |
| const choice = parsed.choices?.[0]; | |
| if (!choice) continue; | |
| const delta = choice.delta || {}; | |
| const parts = []; | |
| if (delta.reasoning_content) { | |
| parts.push({ text: delta.reasoning_content, thought: true }); | |
| } | |
| if (delta.content) { | |
| parts.push({ text: delta.content }); | |
| } | |
| // Skip pure role-only deltas with no content and no finish signal | |
| if (parts.length === 0 && !choice.finish_reason) continue; | |
| const candidate = { | |
| content: { | |
| role: "model", | |
| parts: parts.length > 0 ? parts : [{ text: "" }], | |
| }, | |
| index: 0, | |
| }; | |
| if (choice.finish_reason) { | |
| candidate.finishReason = FINISH_REASON_MAP[choice.finish_reason] || "STOP"; | |
| } | |
| const geminiChunk = { candidates: [candidate] }; | |
| // Attach usage + modelVersion on the final chunk (when finish_reason is set) | |
| if (choice.finish_reason && parsed.usage) { | |
| geminiChunk.usageMetadata = { | |
| promptTokenCount: parsed.usage.prompt_tokens || 0, | |
| candidatesTokenCount: parsed.usage.completion_tokens || 0, | |
| totalTokenCount: parsed.usage.total_tokens || 0, | |
| }; | |
| const reasoningTokens = | |
| parsed.usage.completion_tokens_details?.reasoning_tokens; | |
| if (reasoningTokens) { | |
| geminiChunk.usageMetadata.thoughtsTokenCount = reasoningTokens; | |
| } | |
| geminiChunk.modelVersion = parsed.model || model; | |
| } | |
| controller.enqueue( | |
| encoder.encode("data: " + JSON.stringify(geminiChunk) + "\r\n\r\n") | |
| ); | |
| } | |
| }, | |
| // No flush() needed: Gemini SSE ends by stream close, not a sentinel | |
| }); | |
| return new Response(upstreamResponse.body.pipeThrough(transformStream), { | |
| status: 200, | |
| headers: { | |
| "Content-Type": "text/event-stream", | |
| "Cache-Control": "no-cache", | |
| "Access-Control-Allow-Origin": "*", | |
| }, | |
| }); | |
| } | |
| /** | |
| * Convert an OpenAI chat.completion JSON response into a Gemini | |
| * GenerateContentResponse so that Gemini CLI can parse it. | |
| */ | |
| async function convertOpenAIResponseToGemini(response, model) { | |
| if (!response.ok) return response; | |
| let body; | |
| try { | |
| body = await response.json(); | |
| } catch { | |
| return response; | |
| } | |
| if (body.candidates) return Response.json(body, { | |
| headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } | |
| }); | |
| if (body.error) return Response.json(body, { | |
| status: response.status, | |
| headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } | |
| }); | |
| const choice = body.choices?.[0]; | |
| if (!choice) { | |
| return Response.json(body, { | |
| headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } | |
| }); | |
| } | |
| const { message, finish_reason } = choice; | |
| const parts = []; | |
| if (message.reasoning_content) { | |
| parts.push({ text: message.reasoning_content, thought: true }); | |
| } | |
| parts.push({ text: message.content || "" }); | |
| const finishReason = FINISH_REASON_MAP[finish_reason] || "STOP"; | |
| const geminiResponse = { | |
| candidates: [ | |
| { | |
| content: { role: "model", parts }, | |
| finishReason, | |
| index: 0, | |
| }, | |
| ], | |
| modelVersion: body.model || model, | |
| }; | |
| if (body.usage) { | |
| geminiResponse.usageMetadata = { | |
| promptTokenCount: body.usage.prompt_tokens || 0, | |
| candidatesTokenCount: body.usage.completion_tokens || 0, | |
| totalTokenCount: body.usage.total_tokens || 0, | |
| }; | |
| const reasoningTokens = body.usage.completion_tokens_details?.reasoning_tokens; | |
| if (reasoningTokens) { | |
| geminiResponse.usageMetadata.thoughtsTokenCount = reasoningTokens; | |
| } | |
| } | |
| return Response.json(geminiResponse, { | |
| headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } | |
| }); | |
| } | |