import { existsSync, readFileSync } from "node:fs"; import { hasCredentialSource, redactCredential, resolveCredential } from "./credential-source.ts"; import { getWebSearchConfigPath } from "./utils.ts"; import { registerWebConfigInvalidator } from "./web-config-cache.ts"; const DEFAULT_API_HOST = "https://generativelanguage.googleapis.com"; const API_VERSION = "v1beta"; export const API_BASE = `${DEFAULT_API_HOST}/${API_VERSION}`; const CONFIG_PATH = getWebSearchConfigPath(); export const DEFAULT_MODEL = "gemini-3-flash-preview"; interface GeminiApiConfig { geminiApiKey?: unknown; geminiBaseUrl?: unknown; cloudflareApiKey?: unknown; } let cachedConfig: GeminiApiConfig | null = null; function loadConfig(): GeminiApiConfig { if (cachedConfig) return cachedConfig; if (!existsSync(CONFIG_PATH)) { cachedConfig = {}; return cachedConfig; } const raw = readFileSync(CONFIG_PATH, "utf-8"); try { cachedConfig = JSON.parse(raw) as GeminiApiConfig; return cachedConfig; } catch (err) { const message = err instanceof Error ? err.message : String(err); throw new Error(`Failed to parse ${CONFIG_PATH}: ${message}`); } } export function clearGeminiApiConfigCache(): void { cachedConfig = null; } registerWebConfigInvalidator(clearGeminiApiConfigCache); function withTimeout(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal { const timeout = AbortSignal.timeout(timeoutMs); return signal ? AbortSignal.any([signal, timeout]) : timeout; } function normalizeApiKey(value: unknown): string | null { if (typeof value !== "string") return null; const normalized = value.trim(); return normalized.length > 0 ? normalized : null; } function normalizeBaseUrl(value: unknown): string | null { if (typeof value !== "string") return null; const normalized = value.trim().replace(/\/+$/, ""); return normalized.length > 0 ? normalized : null; } function isCloudflareGateway(): boolean { return getApiHost().includes("gateway.ai.cloudflare.com"); } export async function getApiKey(signal?: AbortSignal): Promise { return resolveCredential({ provider: "Gemini", configuredValue: loadConfig().geminiApiKey, environmentValue: process.env.GEMINI_API_KEY, signal, }); } export function getApiHost(): string { return ( normalizeBaseUrl(process.env.GOOGLE_GEMINI_BASE_URL) ?? normalizeBaseUrl(loadConfig().geminiBaseUrl) ?? DEFAULT_API_HOST ); } export function getVersionedApiBase(): string { return `${getApiHost()}/${API_VERSION}`; } function getLegacyCloudflareApiKey(): string | null { return normalizeApiKey(process.env.CLOUDFLARE_API_KEY) ?? normalizeApiKey(loadConfig().cloudflareApiKey); } async function resolveCloudflareApiKey(signal?: AbortSignal): Promise { return resolveCredential({ provider: "Cloudflare", configuredValue: loadConfig().cloudflareApiKey, environmentValue: process.env.CLOUDFLARE_API_KEY, signal, }); } export function getCloudflareApiKey(): string | null { return getLegacyCloudflareApiKey(); } export function isGatewayConfigured(): boolean { return isCloudflareGateway() && hasCredentialSource({ provider: "Cloudflare", configuredValue: loadConfig().cloudflareApiKey, environmentValue: process.env.CLOUDFLARE_API_KEY, }); } export function buildAuthHeaders(apiKey: string | null = null, cloudflareApiKey: string | null = getLegacyCloudflareApiKey()): Record { if (!isCloudflareGateway()) return apiKey ? { "x-goog-api-key": apiKey } : {}; return cloudflareApiKey ? { "cf-aig-authorization": `Bearer ${cloudflareApiKey}` } : {}; } function redactGeminiCredentials(text: string, apiKey: string | null | undefined, cloudflareApiKey: string | null | undefined): string { return redactCredential(redactCredential(text, apiKey), cloudflareApiKey); } const responseCredentials = new WeakMap(); export function redactGeminiApiResponse(response: Response, text: string, apiKey?: string | null): string { const credentials = responseCredentials.get(response); return redactGeminiCredentials(text, credentials?.apiKey ?? apiKey, credentials?.cloudflareApiKey); } export async function fetchGeminiApi( url: string | URL, init: RequestInit = {}, apiKey?: string | null, ): Promise { const parsedUrl = new URL(url); for (const name of parsedUrl.searchParams.keys()) { if (["key", "api_key"].includes(name.toLowerCase())) { throw new Error("Gemini API credential query parameters are not allowed"); } } const resolvedApiKey = apiKey === undefined ? await getApiKey(init.signal ?? undefined) : apiKey; const cloudflareApiKey = isCloudflareGateway() ? await resolveCloudflareApiKey(init.signal ?? undefined) : null; const allowedOrigins = new Set([ new URL(getApiHost()).origin, new URL(DEFAULT_API_HOST).origin, ]); if ((resolvedApiKey || isGatewayConfigured()) && !allowedOrigins.has(parsedUrl.origin)) { throw new Error("Gemini API request host is not allowed"); } const headers = new Headers(init.headers); headers.delete("x-goog-api-key"); headers.delete("cf-aig-authorization"); for (const [name, value] of Object.entries(buildAuthHeaders(resolvedApiKey, cloudflareApiKey))) { headers.set(name, value); } try { const response = await fetch(parsedUrl, { ...init, headers }); responseCredentials.set(response, { apiKey: resolvedApiKey, cloudflareApiKey }); return response; } catch (error) { const message = error instanceof Error ? error.message : String(error); const redactedMessage = redactGeminiCredentials(message, resolvedApiKey, cloudflareApiKey); if (redactedMessage === message) throw error; const redactedError = new Error(redactedMessage); if (error instanceof Error) redactedError.name = error.name; throw redactedError; } } export function isGeminiApiAvailable(): boolean { return hasCredentialSource({ provider: "Gemini", configuredValue: loadConfig().geminiApiKey, environmentValue: process.env.GEMINI_API_KEY, }) || isGatewayConfigured(); } export interface GeminiApiOptions { apiKey?: string; model?: string; mimeType?: string; signal?: AbortSignal; timeoutMs?: number; } export async function queryGeminiApiWithVideo( prompt: string, videoUri: string, options: GeminiApiOptions = {}, ): Promise { const signal = withTimeout(options.signal, options.timeoutMs ?? 120000); const apiKey = options.apiKey ?? await getApiKey(signal); if (!apiKey && !isGatewayConfigured()) { throw new Error( "Gemini API not configured. Either:\n" + ` 1. Configure geminiApiKey in ${CONFIG_PATH} or set GEMINI_API_KEY\n` + " 2. Set GOOGLE_GEMINI_BASE_URL + CLOUDFLARE_API_KEY for Cloudflare AI Gateway routing" ); } const model = options.model ?? DEFAULT_MODEL; const url = `${getVersionedApiBase()}/models/${model}:generateContent`; const fileData: Record = { fileUri: videoUri }; if (options.mimeType) fileData.mimeType = options.mimeType; const body = { contents: [ { role: "user", parts: [ { fileData }, { text: prompt }, ], }, ], }; const res = await fetchGeminiApi(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), signal, }, apiKey); if (!res.ok) { const errorText = redactGeminiApiResponse(res, await res.text(), apiKey); throw new Error(`Gemini API error ${res.status}: ${errorText.slice(0, 300)}`); } const data = (await res.json()) as GenerateContentResponse; const text = data.candidates?.[0]?.content?.parts ?.map((p) => p.text) .filter(Boolean) .join("\n"); if (!text) throw new Error("Gemini API returned empty response"); return text; } interface GenerateContentResponse { candidates?: Array<{ content?: { parts?: Array<{ text?: string }>; }; }>; }