mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +00:00
The Gemini token route now answers a missing key with code API_KEY_MISSING, the session carries it to the error state as missingKey, and the voice control hides "Check microphone permission" for that error, as it already does for OpenAI. The OpenAI controller sends the same flag, so the shared control and the controller agree whichever updates last.
216 lines
7.6 KiB
JavaScript
216 lines
7.6 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';
|
|
import { resolveOverpassUpstreams } from '../overpass/constants.js';
|
|
|
|
const UPSTREAM_RESPONSE_BYTES = 32 * 1024;
|
|
const MINT_FAILURE = 'Gemini voice could not connect. Try again.';
|
|
|
|
export function createGeminiTokenHandler({
|
|
annotationGuidance,
|
|
tools,
|
|
// osm_query is offered only when an operator configured Overpass.
|
|
overpassConfigured = () => resolveOverpassUpstreams().length > 0,
|
|
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, {
|
|
error: 'Add a Gemini API key in Provider Settings',
|
|
code: 'API_KEY_MISSING',
|
|
});
|
|
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,
|
|
{ overpass: overpassConfigured() },
|
|
);
|
|
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);
|
|
}
|
|
};
|
|
}
|