mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
209 lines
7.3 KiB
JavaScript
209 lines
7.3 KiB
JavaScript
import { waitForSignal } from '../../../src/sources/requestDeadline.js';
|
|
import { clientKey, makeCostRateLimiter } from '../common/rate-limit.js';
|
|
import { readResponseJsonCapped } from '../../../src/sources/httpBody.js';
|
|
import {
|
|
GEMINI_TOKEN_ENDPOINT,
|
|
GEMINI_LIVE_WEBSOCKET_URL,
|
|
GEMINI_LIVE_MODEL_DEFAULT,
|
|
createGeminiLiveConfig,
|
|
normalizeGeminiModel,
|
|
} from './config.js';
|
|
import {
|
|
isGeminiRequestOriginAllowed,
|
|
readGeminiTokenRequest,
|
|
} from './request.js';
|
|
|
|
const UPSTREAM_RESPONSE_BYTES = 32 * 1024;
|
|
const MINT_FAILURE = 'Gemini voice could not connect. Try again.';
|
|
|
|
export function createGeminiTokenHandler({
|
|
annotationGuidance,
|
|
tools,
|
|
fetchImpl = (...args) => fetch(...args),
|
|
resolveApiKey = () => process.env.GEMINI_API_KEY,
|
|
resolveModel = () =>
|
|
process.env.GEMINI_LIVE_MODEL || GEMINI_LIVE_MODEL_DEFAULT,
|
|
resolveHost = () => process.env.HOST,
|
|
resolveAllowedHosts = () => process.env.GEV_ALLOWED_HOSTS,
|
|
resolveRateLimit = () => process.env.GEV_RATELIMIT_GEMINI_PER_MIN ?? '30',
|
|
now = Date.now,
|
|
timeoutMs = 15_000,
|
|
} = {}) {
|
|
let limiter;
|
|
return async (req, res) => {
|
|
if (req.aborted || res.destroyed || res.writableEnded) return;
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
const reply = (status, errorOrPayload) => {
|
|
if (res.destroyed || res.writableEnded) return;
|
|
res.statusCode = status;
|
|
if (status >= 400) res.setHeader('Connection', 'close');
|
|
res.end(
|
|
JSON.stringify(
|
|
typeof errorOrPayload === 'string'
|
|
? { error: errorOrPayload }
|
|
: errorOrPayload,
|
|
),
|
|
);
|
|
};
|
|
if (req.method !== 'POST') {
|
|
res.setHeader('Allow', 'POST');
|
|
return reply(405, 'Method not allowed');
|
|
}
|
|
let apiKey;
|
|
let model;
|
|
try {
|
|
if (
|
|
!isGeminiRequestOriginAllowed(req, resolveHost(), resolveAllowedHosts())
|
|
)
|
|
return reply(403, 'Gemini voice requires a same-origin app request');
|
|
apiKey = String(resolveApiKey() || '').trim();
|
|
if (!apiKey)
|
|
return reply(503, 'Add a Gemini API key in Provider Settings');
|
|
model = normalizeGeminiModel(resolveModel());
|
|
if (!model)
|
|
return reply(503, 'The Gemini Live model configuration is invalid');
|
|
if (limiter === undefined)
|
|
limiter = makeCostRateLimiter(resolveRateLimit(), 30);
|
|
if (limiter && !limiter(clientKey(req))) {
|
|
res.setHeader('Retry-After', '60');
|
|
return reply(
|
|
429,
|
|
'Gemini voice is starting too often. Try again shortly.',
|
|
);
|
|
}
|
|
} catch {
|
|
// Connect does not observe the handler's returned promise. A synchronous
|
|
// configuration resolver must fail with a bounded, fixed response.
|
|
return reply(503, MINT_FAILURE);
|
|
}
|
|
|
|
const lifetime = new AbortController();
|
|
let disconnected = false;
|
|
const disconnect = () => {
|
|
disconnected = true;
|
|
lifetime.abort(new DOMException('Request closed', 'AbortError'));
|
|
};
|
|
const responseClosed = () => {
|
|
if (!res.writableEnded) disconnect();
|
|
};
|
|
req.once('aborted', disconnect);
|
|
res.once('close', responseClosed);
|
|
const deadline = setTimeout(() => {
|
|
lifetime.abort(
|
|
new DOMException('Token request timed out', 'TimeoutError'),
|
|
);
|
|
}, timeoutMs);
|
|
deadline.unref?.();
|
|
let acquiredResponse;
|
|
try {
|
|
const inputMode = await readGeminiTokenRequest(req, lifetime.signal);
|
|
lifetime.signal.throwIfAborted();
|
|
const config = createGeminiLiveConfig(
|
|
annotationGuidance,
|
|
inputMode,
|
|
tools,
|
|
);
|
|
const issuedAt = now();
|
|
// REST uses bidiGenerateContentSetup. liveConnectConstraints is the SDK
|
|
// input shape, converted before transport. With no fieldMask the complete
|
|
// setup is locked to this server-owned configuration.
|
|
const request = {
|
|
uses: 1,
|
|
expireTime: new Date(issuedAt + 30 * 60_000).toISOString(),
|
|
newSessionExpireTime: new Date(issuedAt + 60_000).toISOString(),
|
|
bidiGenerateContentSetup: { model: `models/${model}`, ...config },
|
|
};
|
|
const acquisition = Promise.resolve(
|
|
fetchImpl(GEMINI_TOKEN_ENDPOINT, {
|
|
method: 'POST',
|
|
redirect: 'error',
|
|
signal: lifetime.signal,
|
|
headers: {
|
|
'x-goog-api-key': apiKey,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify(request),
|
|
}),
|
|
);
|
|
// A fetch implementation can ignore abort. Bound this caller's wait and
|
|
// discard a response that arrives after timeout/disconnection.
|
|
void acquisition.then(
|
|
(late) => {
|
|
acquiredResponse = late;
|
|
if (lifetime.signal.aborted) void late.body?.cancel().catch(() => {});
|
|
},
|
|
() => {},
|
|
);
|
|
const response = await waitForSignal(acquisition, lifetime.signal);
|
|
lifetime.signal.throwIfAborted();
|
|
if (
|
|
response.redirected ||
|
|
(response.url && response.url !== GEMINI_TOKEN_ENDPOINT)
|
|
) {
|
|
void response.body?.cancel().catch(() => {});
|
|
throw new Error('Unexpected token response destination');
|
|
}
|
|
if (!response.ok) {
|
|
// Never forward upstream bodies, headers or exception messages.
|
|
void response.body?.cancel().catch(() => {});
|
|
if (response.status === 429) {
|
|
res.setHeader('Retry-After', '60');
|
|
return reply(429, 'Gemini quota is unavailable. Try again later.');
|
|
}
|
|
if (response.status === 401 || response.status === 403)
|
|
return reply(
|
|
503,
|
|
'Gemini access was refused. Check the API key and model access.',
|
|
);
|
|
if (response.status === 400 || response.status === 404)
|
|
return reply(
|
|
502,
|
|
'Gemini Live is unavailable for the configured model.',
|
|
);
|
|
return reply(502, MINT_FAILURE);
|
|
}
|
|
const data = await readResponseJsonCapped(
|
|
response,
|
|
UPSTREAM_RESPONSE_BYTES,
|
|
lifetime.signal,
|
|
);
|
|
lifetime.signal.throwIfAborted();
|
|
if (
|
|
!data ||
|
|
Array.isArray(data) ||
|
|
typeof data !== 'object' ||
|
|
typeof data.name !== 'string' ||
|
|
data.name.length > 8192 ||
|
|
!/^auth_tokens\/[A-Za-z0-9._~+/=-]+$/.test(data.name) ||
|
|
data.name.includes(apiKey)
|
|
)
|
|
throw new Error('Invalid ephemeral token');
|
|
reply(200, {
|
|
token: data.name,
|
|
model,
|
|
config,
|
|
websocketUrl: GEMINI_LIVE_WEBSOCKET_URL,
|
|
});
|
|
} catch (error) {
|
|
if (disconnected || req.aborted) return;
|
|
if (error?.code === 'BODY_TOO_LARGE')
|
|
return reply(413, 'Token request is too large');
|
|
if (error?.code === 'BAD_CONTENT_TYPE')
|
|
return reply(415, 'Use an empty JSON token request');
|
|
if (error?.code === 'BAD_BODY')
|
|
return reply(400, 'Use an empty JSON token request');
|
|
reply(lifetime.signal.aborted ? 504 : 502, MINT_FAILURE);
|
|
} finally {
|
|
// Abort can happen after acquisition resolves but before this handler
|
|
// resumes and passes ownership to the body reader.
|
|
if (lifetime.signal.aborted)
|
|
void acquiredResponse?.body?.cancel().catch(() => {});
|
|
clearTimeout(deadline);
|
|
req.off('aborted', disconnect);
|
|
res.off('close', responseClosed);
|
|
}
|
|
};
|
|
}
|