/** * Gemini Files API upload (raw REST, resumable protocol), ported from the * Python `files_api_part` in `skills/rap-avatar-mv/scripts/song_breakdown.py`. * * Sequence: `POST /upload/v1beta/files` (start, returns `X-Goog-Upload-URL`) → * `POST ` with the bytes (`upload, finalize`) → poll * `GET /v1beta/files/` until `state === 'ACTIVE'`. * * Every request rides `fetchGeminiWithPool` with `keyOverride`: an uploaded * file is visible ONLY to the key that uploaded it, so the caller pins one key * here and must reuse it on the request that references the returned `uri`. * The bytes POST runs with `maxAttempts: 1` — a pool retry would re-send the * whole body at offset 0 to a possibly-finalized session. */ import { readFile } from 'node:fs/promises'; import { statSync } from 'node:fs'; import { basename, extname } from 'node:path'; import { fetchGeminiWithPool } from './gemini-key-pool.js'; import { resolveGeminiApiBase, withKey } from './gemini-api-base.js'; import { safeErrorBody } from './http-error-safety.js'; /** Files API hard cap per file (2 GiB). Checked before any byte moves. */ export const GEMINI_FILES_MAX_BYTES = 2 * 1024 * 1024 * 1024; export interface GeminiUploadedFile { /** Resource name, e.g. `files/abc123`. */ name: string; /** The `uri` a video input references. */ uri: string; mimeType: string; sizeBytes: number; } export function videoMimeTypeForPath(path: string): string { switch (extname(path).toLowerCase()) { case '.mov': return 'video/quicktime'; case '.webm': return 'video/webm'; case '.mkv': return 'video/x-matroska'; case '.avi': return 'video/x-msvideo'; case '.mpeg': case '.mpg': return 'video/mpeg'; case '.3gp': return 'video/3gpp'; case '.mp4': case '.m4v': default: return 'video/mp4'; } } type FileResource = { name?: string; uri?: string; state?: string; mimeType?: string; error?: { message?: string } }; function fileResourceOf(payload: unknown): FileResource { const wrapped = payload as { file?: FileResource } | FileResource; return (wrapped as { file?: FileResource }).file ?? (wrapped as FileResource); } export async function uploadGeminiFile(input: { path: string; /** REQUIRED: the one key this upload (and the later Interactions call) uses. */ key: string; mimeType?: string; displayName?: string; apiBase?: string; fetcher?: typeof fetch; sleep?: (ms: number) => Promise; pollIntervalMs?: number; maxPollAttempts?: number; /** Test seam for the size guard; defaults to the real file size. */ sizeBytes?: number; }): Promise { const key = input.key.trim(); if (key === '') throw new Error('uploadGeminiFile needs a non-empty key (files are scoped to the uploading key).'); const apiBase = input.apiBase ?? resolveGeminiApiBase(); const mimeType = input.mimeType ?? videoMimeTypeForPath(input.path); // Size guard on the stat BEFORE reading anything into memory; the header // below is then sized from the bytes actually read, so a file still being // written cannot announce one length and send another. const statedBytes = input.sizeBytes ?? statSync(input.path).size; if (statedBytes > GEMINI_FILES_MAX_BYTES) { throw new Error( `${input.path} is ${statedBytes} bytes; the Gemini Files API accepts at most ${GEMINI_FILES_MAX_BYTES} bytes per file.`, ); } const bytes = await readFile(input.path); const sizeBytes = bytes.length; const sleep = input.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); const pollIntervalMs = input.pollIntervalMs ?? 5000; const maxPollAttempts = input.maxPollAttempts ?? 120; const poolOptions = { fetcher: input.fetcher, keyOverride: key }; // 1. Start the resumable session. const start = await fetchGeminiWithPool( (k) => withKey(`${apiBase}/upload/v1beta/files`, k), { method: 'POST', headers: { 'X-Goog-Upload-Protocol': 'resumable', 'X-Goog-Upload-Command': 'start', 'X-Goog-Upload-Header-Content-Length': String(sizeBytes), 'X-Goog-Upload-Header-Content-Type': mimeType, 'Content-Type': 'application/json', 'Connection': 'close', }, body: JSON.stringify({ file: { display_name: input.displayName ?? basename(input.path) } }), }, poolOptions, ); if (!start.ok) { const body = await start.text().catch(() => ''); throw new Error(`Gemini Files API start failed with HTTP ${start.status}: ${safeErrorBody(body)}`); } const uploadUrl = start.headers.get('x-goog-upload-url'); if (!uploadUrl) { throw new Error('Gemini Files API start response carried no X-Goog-Upload-URL header.'); } // 2. Send the bytes and finalize. The session URL is pre-authenticated; never retry. process.stderr.write(`[analyze/gemini] uploading ${basename(input.path)} (${sizeBytes} bytes) to the Files API\n`); const upload = await fetchGeminiWithPool( () => uploadUrl, { method: 'POST', headers: { 'X-Goog-Upload-Offset': '0', 'X-Goog-Upload-Command': 'upload, finalize', 'Content-Type': mimeType, 'Connection': 'close', }, body: bytes, }, { ...poolOptions, maxAttempts: 1 }, ); if (!upload.ok) { const body = await upload.text().catch(() => ''); throw new Error(`Gemini Files API upload failed with HTTP ${upload.status}: ${safeErrorBody(body)}`); } let file = fileResourceOf(await upload.json().catch(() => ({}))); if (!file.name || !file.uri) { throw new Error('Gemini Files API upload response carried no file name/uri.'); } const fileId = file.name.split('/').pop() ?? file.name; // 3. Poll until ACTIVE. for (let attempt = 0; file.state !== 'ACTIVE'; attempt += 1) { if (file.state === 'FAILED') { throw new Error(`Gemini Files API processing failed for ${file.name}: ${file.error?.message ?? 'no message'}`); } if (attempt >= maxPollAttempts) { throw new Error(`Gemini Files API file ${file.name} did not become ACTIVE after ${maxPollAttempts} polls.`); } await sleep(pollIntervalMs); const poll = await fetchGeminiWithPool( (k) => withKey(`${apiBase}/v1beta/files/${fileId}`, k), { method: 'GET', headers: { 'Connection': 'close' } }, poolOptions, ); if (!poll.ok) { const body = await poll.text().catch(() => ''); throw new Error(`Gemini Files API poll failed with HTTP ${poll.status}: ${safeErrorBody(body)}`); } file = { ...file, ...fileResourceOf(await poll.json().catch(() => ({}))) }; } const name = file.name; const uri = file.uri; if (!name || !uri) { throw new Error('Gemini Files API poll response dropped the file name/uri.'); } return { name, uri, mimeType: file.mimeType ?? mimeType, sizeBytes }; }