Merge main into the voice tilt branch

No conflicts. The tool payload pin and the Realtime prompt size are unchanged (55,492 with Overpass).
This commit is contained in:
Manjunath committed 2026-11-04 13:45:45 +05:30
commit bf73a8a1e1
128 files changed
+14952 -173

No files matched your search

+6
View File
@@ -28,6 +28,7 @@ import {
loadNycSourcesFromOpenData,
loadTorontoSourcesFromOpenData,
loadFloridaSourcesFromOpenData,
loadHoustonSourcesFromOpenData,
loadA22SourcesFromOpenDataHub,
} from './sources.js';
@@ -166,6 +167,11 @@ const LIVE_PACKS = [
enabled: () => envEnabled('CCTV_TAIWAN_ENABLED'),
load: loadTaiwanSources,
},
{
name: 'houston',
enabled: () => envOptIn('CCTV_HOUSTON_ENABLED'),
load: loadHoustonSourcesFromOpenData,
},
{
name: 'a22',
enabled: () => envEnabled('CCTV_A22_ENABLED'),
+31
View File
@@ -443,6 +443,37 @@ export const NYC_ANCHORS = [
{ lat: 40.5795, lon: -74.1502 }, // Staten Island
];
/** Houston TranStar cameras: one keyless JSON list for the metro (~1,000
* cameras with a numeric id). Frames are stills synthesized from that id on
* the city's snapshot host — never taken from the payload. The catalog is
* served as `application/javascript`; asking for `application/json` gets HTTP
* 406, so the loader accepts any media type. */
export const HOUSTON_CAMERAS_URL =
'https://traffic.houstontranstar.org/data/layers/cctvSnapshots_json.js';
/** The only origin Houston camera frames may come from. The catalog's `url`
* is a `showWin(...)` viewer helper and is ignored; the registered path is
* always this origin plus `/snapshots/cctv/<id>.jpg`. */
export const HOUSTON_IMAGE_ORIGIN = 'https://www.houstontranstar.org/';
export const HOUSTON_IMAGE_URL = (id) =>
`${HOUSTON_IMAGE_ORIGIN}snapshots/cctv/${id}.jpg`;
/** Numeric TranStar snapshot ids: 3–5 digits, no leading zero. Id `0` is a
* different (non-snapshot) camera class in the same list. */
export const HOUSTON_CAMERA_ID_RE = /^[1-9]\d{2,4}$/;
export const DEFAULT_HOUSTON_MAX_SOURCES = 250;
export const HOUSTON_MAX_CATALOG_BYTES = 4 * 1024 * 1024;
/** Ground-elevation prior in metres. The TranStar payload carries none, and
* Houston sits a few metres above sea level. */
export const HOUSTON_GROUND_ELEVATION_M = 15;
/** Downtown Houston (Main @ Texas): the primary prioritization anchor. */
export const HOUSTON_DOWNTOWN = { lat: 29.7604, lon: -95.3698 };
export const HOUSTON_ANCHORS = [
{ lat: 29.7604, lon: -95.3698 }, // Downtown
{ lat: 29.739, lon: -95.461 }, // Uptown / Galleria
{ lat: 29.71, lon: -95.398 }, // Texas Medical Center
{ lat: 29.9902, lon: -95.3368 }, // IAH
{ lat: 29.3013, lon: -94.7977 }, // Galveston
];
/** Autostrada del Brennero (A22, Brenner–Modena) webcams: the keyless Open
* Data Hub (NOI Techpark, South Tyrol) tourism API republishes Autobrennero's
* own camera list with coordinates, one request for the whole motorway
+12
View File
@@ -410,6 +410,18 @@ export function isLikelyHongKongCoordinate(lat, lon) {
);
}
/** Greater Houston (the Woodlands to Galveston, Katy to Baytown). Farther
* TranStar partners (Dallas, Columbus) are out of this city pack. */
export function isLikelyHoustonCoordinate(lat, lon) {
return (
isPlausibleLatLon(lat, lon) &&
lat >= 29.2 &&
lat <= 30.35 &&
lon >= -96.05 &&
lon <= -94.7
);
}
/** Mainland Norway, with slack for the coast and the Finnmark border. */
export function isLikelyNorwayCoordinate(lat, lon) {
return (
+167
View File
@@ -127,6 +127,13 @@ import {
A22_MAX_CATALOG_BYTES,
A22_GROUND_ELEVATION_BY_KM,
A22_DEFAULT_GROUND_ELEVATION_M,
HOUSTON_CAMERAS_URL,
HOUSTON_IMAGE_URL,
HOUSTON_CAMERA_ID_RE,
DEFAULT_HOUSTON_MAX_SOURCES,
HOUSTON_MAX_CATALOG_BYTES,
HOUSTON_GROUND_ELEVATION_M,
HOUSTON_ANCHORS,
} from './constants.js';
import {
toFiniteNumber,
@@ -147,6 +154,7 @@ import {
isLikelyCataloniaCoordinate,
decodeNumericEntities,
isLikelyNycCoordinate,
isLikelyHoustonCoordinate,
isLikelyHongKongCoordinate,
isLikelyNorwayCoordinate,
isLikelyTorontoCoordinate,
@@ -3771,3 +3779,162 @@ export async function loadA22SourcesFromOpenDataHub() {
return [];
}
}
/**
* Label for one Houston TranStar camera: the interchange an operator
* recognises ("10 EAST @ SAN JACINTO").
*
* @param {object} record - Raw TranStar camera row.
* @param {string} cameraId - Derived stable id.
* @returns {string}
*/
export function houstonCameraName(record, cameraId) {
const name = String(record?.location ?? '').trim();
if (name) return name;
const short = String(cameraId || '').replace(/^houston-/, '');
return short ? `Houston Camera ${short}` : 'Houston Camera';
}
/**
* TranStar `dir` (the camera's side of the freeway) -> the travel direction
* of the nearest lanes, as TranStar's map script `translateCameraDirection`
* reads it. Only these four values are mapped; anything else is unknown.
*/
const HOUSTON_TRAVEL_HEADING_BY_SIDE = Object.freeze({
south: 90,
north: 270,
west: 180,
east: 0,
});
/**
* One Houston TranStar camera row -> one catalog source, or null.
*
* Frame URLs are synthesized from a validated numeric id on the pinned
* snapshot origin. The payload's `url` is a `showWin(...)` viewer helper
* and is ignored so a catalog edit cannot steer the frame proxy off-host.
* TranStar's `dir` is the side of the freeway the camera sits on, not the
* way it faces: TranStar's own map labels it "Traffic closest to the camera
* is traveling …" (South→Eastbound, North→Westbound, West→Southbound,
* East→Northbound). That travel direction is only a low-confidence prior
* for the view, so the heading stays estimated with the low-confidence
* pose; `None`, blank or unknown values fall back to the shared id-hash
* heading.
*
* @param {object} record - Raw TranStar camera row.
* @returns {?object}
*/
export function houstonCameraToSource(record) {
if (!record || typeof record !== 'object') return null;
const lat = toFiniteNumber(record.lat);
const lon = toFiniteNumber(record.lng);
if (!isLikelyHoustonCoordinate(lat, lon)) return null;
const rawId = String(record.id ?? '').trim();
if (!HOUSTON_CAMERA_ID_RE.test(rawId)) return null;
const cameraId = `houston-${rawId}`;
const imageUrl = HOUSTON_IMAGE_URL(rawId);
const name = houstonCameraName(record, cameraId);
const travelHeading =
HOUSTON_TRAVEL_HEADING_BY_SIDE[
String(record.dir ?? '')
.trim()
.toLowerCase()
];
return {
id: cameraId,
name,
city: 'Houston',
cityId: 'houston',
provider: 'Houston TranStar',
lat,
lon,
headingDeg: Number.isFinite(travelHeading)
? travelHeading
: fallbackHeadingFromId(cameraId),
headingConfidence: 'low',
pitchDeg: -18,
fovDeg: 44,
rangeM: 145,
mountHeightM: 8,
groundElevationM: HOUSTON_GROUND_ELEVATION_M,
feedType: 'image',
url: imageUrl,
snapshotUrl: imageUrl,
sourceKind: 'houston-transtar',
license:
'TranStar disclaimer — redistribution and external framing need TxDOT written permission',
code: cameraDisplayCode(name.toUpperCase()),
};
}
/**
* Fetch Houston TranStar cameras from the keyless `cctvSnapshots_json.js`
* catalog. Frames are stills on `www.houstontranstar.org`, registered from
* a validated numeric id rather than from the payload URL. The catalog host
* answers `Accept: application/json` with HTTP 406, so this request accepts
* any media type.
*
* @returns {Promise<Array<object>>} Normalized camera source objects.
*/
export async function loadHoustonSourcesFromOpenData() {
try {
const endpoint =
process.env.CCTV_HOUSTON_CAMERAS_URL || HOUSTON_CAMERAS_URL;
const resp = await fetch(endpoint, {
headers: { Accept: '*/*' },
redirect: 'manual',
signal: AbortSignal.timeout(CCTV_SOURCE_FETCH_TIMEOUT_MS),
});
const discard = async () => {
try {
await resp.body?.cancel();
} catch {
/* no-op */
}
return [];
};
if (resp.status >= 300 && resp.status < 400) {
console.warn(
'[CCTV] Houston catalog redirected; redirects are not followed',
);
return discard();
}
if (!resp.ok) {
console.warn('[CCTV] Houston camera download failed:', resp.status);
return discard();
}
const payload = await readResponseJsonCapped(
resp,
HOUSTON_MAX_CATALOG_BYTES,
);
const rows = Array.isArray(payload?.cameras) ? payload.cameras : [];
const cameras = [];
const seen = new Set();
for (const record of rows) {
const camera = houstonCameraToSource(record);
if (!camera || seen.has(camera.id)) continue;
seen.add(camera.id);
cameras.push(camera);
}
const maxRaw = Number(
process.env.CCTV_HOUSTON_MAX_SOURCES || DEFAULT_HOUSTON_MAX_SOURCES,
);
const maxCount = Number.isFinite(maxRaw)
? Math.max(8, Math.min(400, Math.floor(maxRaw)))
: DEFAULT_HOUSTON_MAX_SOURCES;
const prioritized = prioritizeSources(cameras, maxCount, HOUSTON_ANCHORS);
console.log(
`[CCTV] Loaded Houston camera sources: ${cameras.length} (using nearest ${prioritized.length})`,
);
return prioritized;
} catch (error) {
console.warn(
'[CCTV] Houston camera download error:',
error?.message || error,
);
return [];
}
}
+221
View File
@@ -0,0 +1,221 @@
import { readRequestBody } from './request.js';
import {
clientKey,
makeCostRateLimiter,
resolvePerMinuteCap,
} from './rate-limit.js';
import { waitForSignal } from '../../../src/sources/requestDeadline.js';
import { readResponseJsonCapped } from '../../../src/sources/httpBody.js';
import { isGeminiRequestOriginAllowed } from '../gemini/request.js';
import { parseUsage } from './textTools.js';
const MAX_BYTES = 512 * 1024;
const FAILURE = 'Could not connect. Try again.';
// A rejected key is not retried upstream for this long, unless the key changes.
const REJECTED_KEY_COOLDOWN_MS = 60_000;
// What an upstream failure says about the key or the account. Gemini answers
// an invalid key with 400 and an ErrorInfo reason, not 401; OpenAI answers an
// account without credit with 429 insufficient_quota, which waiting won't fix.
// A 403 is the key's project, region or permissions, so it cools down too but
// does not blame the key. Only small error bodies are read.
async function upstreamRefusal(response, signal) {
const { status } = response;
if (status === 401) return 'API_KEY_REJECTED';
if (status === 403) return 'ACCESS_DENIED';
if (![400, 429].includes(status)) return null;
let error;
try {
error = (await readResponseJsonCapped(response, 16 * 1024, signal))?.error;
} catch {
return null;
}
if (
status === 400 &&
Array.isArray(error?.details) &&
error.details.some((detail) => detail?.reason === 'API_KEY_INVALID')
)
return 'API_KEY_REJECTED';
if (status === 429 && error?.code === 'insufficient_quota')
return 'QUOTA_EXCEEDED';
return null;
}
// Pass through only a delay in whole seconds, capped at an hour.
function upstreamRetryAfter(response) {
const value = String(response.headers?.get?.('retry-after') ?? '').trim();
if (!/^\d{1,6}$/.test(value)) return null;
return String(Math.min(Math.max(Number(value), 1), 3600));
}
export function createTextChatHandler({
provider,
prepare,
parse,
fetchImpl = (...args) => fetch(...args),
resolveApiKey,
resolveHost = () => process.env.HOST,
resolveAllowedHosts = () => process.env.GEV_ALLOWED_HOSTS,
resolveRateLimit = () =>
process.env[`GEV_RATELIMIT_${provider.toUpperCase()}_CHAT_PER_MIN`],
// An unset chat throttle follows the provider's own cost throttle.
resolveProviderRateLimit = () => undefined,
providerPerMin = 30,
// Returns { error, code } when the route must refuse before any key is used.
refuse = () => null,
now = Date.now,
timeoutMs = 60000,
} = {}) {
let limiter;
let rejected = null;
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, value, code) => {
if (req.aborted || res.destroyed || res.writableEnded) return;
res.statusCode = status;
if (status >= 400) res.setHeader('Connection', 'close');
res.end(
JSON.stringify(
typeof value === 'string'
? { error: value, ...(code ? { code } : {}) }
: value,
),
);
};
if (req.method !== 'POST') {
res.setHeader('Allow', 'POST');
return reply(405, 'Method not allowed');
}
const lifetime = new AbortController();
const disconnect = () => {
if (!res.writableEnded) lifetime.abort();
};
req.once('aborted', disconnect);
res.once('close', disconnect);
const timer = setTimeout(() => lifetime.abort(), timeoutMs);
timer.unref?.();
let response;
let usage = null;
try {
if (
!isGeminiRequestOriginAllowed(req, resolveHost(), resolveAllowedHosts())
)
return reply(403, FAILURE);
const refusal = refuse();
if (refusal) return reply(503, refusal.error, refusal.code);
const apiKey = String(resolveApiKey() || '').trim();
if (!apiKey)
return reply(
503,
'Add an API key in Provider Settings',
'API_KEY_MISSING',
);
if (rejected && (rejected.key !== apiKey || now() >= rejected.until))
rejected = null;
if (rejected) {
res.setHeader(
'Retry-After',
String(Math.max(1, Math.ceil((rejected.until - now()) / 1000))),
);
return reply(502, FAILURE, rejected.code);
}
if (limiter === undefined)
limiter = makeCostRateLimiter(
resolveRateLimit(),
resolvePerMinuteCap(resolveProviderRateLimit(), providerPerMin),
);
if (limiter && !limiter(clientKey(req))) {
res.setHeader('Retry-After', '60');
return reply(429, FAILURE, 'RATE_LIMITED');
}
if (
!/^application\/json(?:\s*;|$)/i.test(req.headers['content-type'] || '')
)
return reply(415, FAILURE);
if (Number(req.headers['content-length']) > MAX_BYTES)
return reply(413, FAILURE);
const raw = await readRequestBody(req, MAX_BYTES, lifetime.signal);
let input;
try {
input = JSON.parse(raw);
} catch {
return reply(400, FAILURE);
}
const prepared = prepare(input);
if (!prepared) return reply(400, FAILURE);
if (prepared.errorStatus) return reply(prepared.errorStatus, FAILURE);
const { endpoint, body, headers } = prepared;
lifetime.signal.throwIfAborted();
const acquisition = Promise.resolve(
fetchImpl(endpoint, {
method: 'POST',
redirect: 'error',
signal: lifetime.signal,
headers: { 'Content-Type': 'application/json', ...headers(apiKey) },
body: JSON.stringify(body),
}),
);
void acquisition.then(
(late) => {
response = late;
if (lifetime.signal.aborted) void late.body?.cancel().catch(() => {});
},
() => {},
);
response = await waitForSignal(acquisition, lifetime.signal);
lifetime.signal.throwIfAborted();
if (response.redirected || (response.url && response.url !== endpoint))
throw new Error('Unexpected destination');
if (!response.ok) {
const refusal = await upstreamRefusal(response, lifetime.signal);
if (refusal) {
void response.body?.cancel().catch(() => {});
rejected = {
key: apiKey,
code: refusal,
until: now() + REJECTED_KEY_COOLDOWN_MS,
};
return reply(502, FAILURE, refusal);
}
void response.body?.cancel().catch(() => {});
if (![429, 503].includes(response.status)) return reply(502, FAILURE);
const retryAfter = upstreamRetryAfter(response);
if (retryAfter) res.setHeader('Retry-After', retryAfter);
return reply(
response.status,
FAILURE,
response.status === 429 ? 'RATE_LIMITED' : undefined,
);
}
const data = await readResponseJsonCapped(
response,
MAX_BYTES,
lifetime.signal,
);
lifetime.signal.throwIfAborted();
usage = parseUsage(provider, data);
const result = parse(data);
if (!result || JSON.stringify(result).includes(apiKey))
return reply(502, { error: FAILURE, usage });
reply(200, { ...result, usage });
} catch (error) {
reply(
error?.code === 'BODY_TOO_LARGE'
? 413
: lifetime.signal.aborted
? 504
: 502,
{ error: FAILURE, usage },
);
} finally {
if (lifetime.signal.aborted)
void response?.body?.cancel().catch(() => {});
clearTimeout(timer);
req.off('aborted', disconnect);
res.off('close', disconnect);
}
};
}
@@ -0,0 +1,32 @@
import { realtimeDirectives } from '../openai/instructions.js';
// Voice wording in the shared directives and its written form. A test checks
// each phrase still occurs in the directives, so rewording one there fails
// rather than leaking spoken wording into chat.
export const TEXT_WORDING = Object.freeze([
['without speaking first', 'without writing a preamble'],
['before speaking', 'before confirming'],
['may first say ONE sentence', 'may first write ONE sentence'],
["speak the result's say line", "write the result's say line"],
['on the voice card: speak a caveat', 'in the tool result: include a caveat'],
['as you talk', 'as you write'],
['you can read aloud', 'you can report'],
]);
// Shared-manual adaptation follows Darren Dignam's GEV COMMAND approach (#976).
// Keep our Radio handoff and annotation outcome policies from the shared manual.
// `options.overpass` matches the tools offered, as for voice.
export function textInstructions(annotationGuidance, options) {
return realtimeDirectives(annotationGuidance, options)
.map((line, index) => {
if (index === 0)
return "You are GEV Chat, a concise text assistant for a Cesium geospatial app called God's Eye View.";
if (index === 1)
return 'Have a natural written conversation. This is text-only chat; no microphone or generated audio is used.';
return TEXT_WORDING.reduce(
(text, [spoken, written]) => text.replace(spoken, written),
line,
);
})
.join('\n');
}
+54
View File
@@ -0,0 +1,54 @@
import { validateValue } from '../../../src/tools/schema.js';
export function invalidCalls(calls, tools) {
const schemas = new Map(tools.map((tool) => [tool.name, tool.parameters]));
return calls.flatMap((call, index) => {
const schema = schemas.get(call.name);
const errors = !schema
? ['Unknown tool']
: call.parseError
? ['Arguments must be valid JSON']
: validateValue(schema, call.args === undefined ? {} : call.args);
return errors.length
? [{ index, error: errors.slice(0, 4).join('; ').slice(0, 1000) }]
: [];
});
}
// Missing usage stays unknown, never an invented zero-cost request.
export function parseUsage(provider, data) {
const raw = provider === 'gemini' ? data?.usageMetadata : data?.usage;
const number = (value) =>
Number.isSafeInteger(value) && value >= 0 ? value : null;
const inputTokens = number(
provider === 'gemini' ? raw?.promptTokenCount : raw?.prompt_tokens,
);
const candidateTokens = number(
provider === 'gemini' ? raw?.candidatesTokenCount : raw?.completion_tokens,
);
const totalTokens = number(
provider === 'gemini' ? raw?.totalTokenCount : raw?.total_tokens,
);
if (inputTokens === null || candidateTokens === null || totalTokens === null)
return null;
const reasoningTokens =
number(
provider === 'gemini'
? raw?.thoughtsTokenCount
: raw?.completion_tokens_details?.reasoning_tokens,
) ?? 0;
const outputTokens =
candidateTokens + (provider === 'gemini' ? reasoningTokens : 0);
return {
inputTokens,
outputTokens,
totalTokens,
reasoningTokens,
cachedInputTokens:
number(
provider === 'gemini'
? raw?.cachedContentTokenCount
: raw?.prompt_tokens_details?.cached_tokens,
) ?? 0,
};
}
+157
View File
@@ -0,0 +1,157 @@
import { normalizeDroneAirspaceSnapshot } from '../../src/layers/droneAirspace/records.js';
import { readResponseJsonCapped, coalesceProxyRequest } from './common/http.js';
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
import { admitSameSiteKeyless } from './common/same-site.js';
// FAA Special Use Airspace (public, keyless). TYPE_CODE P (prohibited) and
// R (restricted) only — the small, always-in-force slice of the national
// SUA table. maxAllowableOffset trades ~100 m of boundary fidelity for a
// payload small enough to refresh (~330 KB for the current P+R set).
const API_URL =
'https://services6.arcgis.com/ssFJjBXIUyZDrSYZ/arcgis/rest/services/' +
'Special_Use_Airspace/FeatureServer/0/query?' +
new URLSearchParams({
where: "TYPE_CODE IN ('P','R')",
outFields: [
'NAME',
'TYPE_CODE',
'UPPER_VAL',
'UPPER_UOM',
'UPPER_CODE',
'LOWER_VAL',
'LOWER_UOM',
'LOWER_CODE',
'TIMESOFUSE',
'CONT_AGENT',
'CITY',
'STATE',
'OBJECTID',
].join(','),
maxAllowableOffset: '0.001',
outSR: '4326',
f: 'geojson',
}).toString();
const MAX_PAGES = 5;
const MIB = 1024 * 1024;
const SNAPSHOT_TTL_MS = 3_600_000;
/** Longest a failed refresh keeps serving the last snapshot, marked stale. */
const STALE_MAX_MS = 24 * 60 * 60 * 1000;
const exceededTransferLimit = (payload) =>
payload?.exceededTransferLimit === true ||
payload?.properties?.exceededTransferLimit === true;
/** Fixed-origin, bounded FAA SUA routes for dev and preview. */
export function droneAirspaceProxy({
fetchImpl = (...args) => globalThis.fetch(...args),
now = () => Date.now(),
} = {}) {
const cache = new Map();
const inFlight = new Map();
const allow = makeRateLimiter({ windowMs: 60_000, max: 60, globalMax: 1200 });
async function upstream(url, cap, timeout) {
const signal = AbortSignal.timeout(timeout);
const response = await fetchImpl(url, {
signal,
redirect: 'error',
});
if (!response.ok) {
await response.body?.cancel();
throw new Error('upstream_unavailable');
}
return readResponseJsonCapped(response, cap, signal);
}
// A snapshot is accepted only once the service says it is complete. A page
// limit reached while still flagged, a flagged page that adds nothing, or
// no valid zone at all is a failed refresh, so the last good snapshot stays.
async function fetchZones() {
const features = [];
let complete = false;
for (let page = 0; page < MAX_PAGES; page++) {
const url =
page === 0 ? API_URL : `${API_URL}&resultOffset=${features.length}`;
const payload = await upstream(url, 8 * MIB, 30_000);
if (!Array.isArray(payload?.features))
throw new Error('invalid_snapshot');
features.push(...payload.features);
if (!exceededTransferLimit(payload)) {
complete = true;
break;
}
if (!payload.features.length) break;
}
if (!complete) throw new Error('incomplete_snapshot');
const rows = normalizeDroneAirspaceSnapshot({ features });
if (!rows?.length) throw new Error('invalid_snapshot');
return { fetchedAt: now(), rows };
}
async function acquire(key, ttl, load) {
const previous = cache.get(key);
if (previous && now() - previous.savedAt < ttl)
return { value: previous.value, stale: false };
if (!inFlight.has(key) && inFlight.size >= 256)
throw Object.assign(new Error('busy'), { status: 429 });
try {
const { promise } = coalesceProxyRequest(inFlight, key, async () => {
const value = await load();
cache.set(key, { value, savedAt: now() });
return value;
});
return { value: await promise, stale: false };
} catch (error) {
// A failed refresh serves the last snapshot, marked stale, for a day.
if (previous && now() - previous.savedAt <= STALE_MAX_MS)
return { value: previous.value, stale: true };
throw error;
}
}
async function handler(req, res) {
const json = (status, value, stale = false) => {
if (res.destroyed) return;
res.writeHead(status, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
...(status === 405 ? { Allow: 'GET' } : {}),
...(status === 429 ? { 'Retry-After': '60' } : {}),
...(stale ? { 'X-Data-Stale': 'true' } : {}),
});
res.end(JSON.stringify(value));
};
// Keyless and needed to render the layer: refuse cross-site browsers
// before they spend a client's quota or start a refresh, but keep the
// documented reverse-proxy deployment working.
if (admitSameSiteKeyless(req, res)) return;
if (req.method !== 'GET') return json(405, { error: 'method_not_allowed' });
const path = (req.url || '/').split('?')[0];
if (path !== '/' && path !== '')
return json(404, { error: 'unknown_route' });
if (!allow(clientKey(req))) return json(429, { error: 'rate_limited' });
try {
const { value, stale } = await acquire(
'zones',
SNAPSHOT_TTL_MS,
fetchZones,
);
json(200, stale ? { ...value, stale: true } : value, stale);
} catch (error) {
json(error.status === 429 ? 429 : 502, {
error: 'drone_airspace_unavailable',
});
}
}
return {
name: 'drone-airspace',
configureServer({ middlewares }) {
middlewares.use('/api/drone-airspace', handler);
},
configurePreviewServer({ middlewares }) {
middlewares.use('/api/drone-airspace', handler);
},
};
}
+5
View File
@@ -1,8 +1,13 @@
import { createGeminiChatHandler } from './gemini/chat.js';
import { createGeminiTokenHandler } from './gemini/realtime.js';
/** Mint constrained Live API credentials; the permanent key stays on Node. */
function geminiLiveProxy({ annotationGuidance, realtime = {} } = {}) {
function install(middlewares) {
middlewares.use(
'/api/gemini/chat',
createGeminiChatHandler({ ...realtime, annotationGuidance }),
);
middlewares.use(
'/api/gemini/token',
createGeminiTokenHandler({ ...realtime, annotationGuidance }),
+188
View File
@@ -0,0 +1,188 @@
import { createTextChatHandler } from '../common/textChat.js';
import { invalidCalls } from '../common/textTools.js';
import { textInstructions } from '../common/textInstructions.js';
import { GEV_REALTIME_TOOLS, offeredTools } from '../openai/tools.js';
import { resolveOverpassUpstreams } from '../overpass/constants.js';
import { normalizeGeminiModel } from './config.js';
// The Gemini token route's throttle default (GEV_RATELIMIT_GEMINI_PER_MIN).
export const GEMINI_DEFAULT_PER_MIN = 30;
const object = (value) =>
value && typeof value === 'object' && !Array.isArray(value);
const keys = (value, allowed) =>
Object.keys(value).every((key) => allowed.includes(key));
// Only text and function exchanges may cross this route, never media or config.
export function validChatContents(contents, names) {
return (
Array.isArray(contents) &&
contents.length > 0 &&
contents.length <= 96 &&
contents.every(
(content) =>
object(content) &&
keys(content, ['role', 'parts']) &&
['user', 'model'].includes(content.role) &&
Array.isArray(content.parts) &&
content.parts.length > 0 &&
content.parts.length <= 32 &&
content.parts.every((part) => {
if (
!object(part) ||
!keys(part, [
'text',
'functionCall',
'functionResponse',
'thoughtSignature',
])
)
return false;
if (
part.thoughtSignature !== undefined &&
(typeof part.thoughtSignature !== 'string' ||
part.thoughtSignature.length > 32000 ||
content.role !== 'model')
)
return false;
if (Object.hasOwn(part, 'text'))
return (
keys(part, ['text', 'thoughtSignature']) &&
typeof part.text === 'string'
);
const call = part.functionCall ?? part.functionResponse;
if (
!object(call) ||
typeof call.name !== 'string' ||
call.name.length > 128 ||
(names && !names.has(call.name)) ||
(call.id !== undefined &&
(typeof call.id !== 'string' || call.id.length > 1024))
)
return false;
if (part.functionCall)
return (
content.role === 'model' &&
keys(part, ['functionCall', 'thoughtSignature']) &&
keys(call, ['name', 'args', 'id']) &&
(call.args === null || object(call.args ?? {}))
);
return (
content.role === 'user' &&
keys(part, ['functionResponse']) &&
keys(call, ['name', 'response', 'id']) &&
object(call.response)
);
}),
)
);
}
export function createGeminiChatHandler({
annotationGuidance,
tools = GEV_REALTIME_TOOLS,
// osm_query is offered, and the prompt names it, only with Overpass, as
// for voice.
overpassConfigured = () => resolveOverpassUpstreams().length > 0,
resolveChatModel = () =>
process.env.GEMINI_CHAT_MODEL || 'gemini-3.5-flash-lite',
resolveApiKey = () => process.env.GEMINI_API_KEY,
resolveProviderRateLimit = () => process.env.GEV_RATELIMIT_GEMINI_PER_MIN,
...options
} = {}) {
return createTextChatHandler({
...options,
provider: 'gemini',
resolveApiKey,
resolveProviderRateLimit,
providerPerMin: GEMINI_DEFAULT_PER_MIN,
prepare(input) {
const model = normalizeGeminiModel(resolveChatModel());
if (!model || /live|audio|tts/i.test(model)) return { errorStatus: 503 };
if (
!object(input) ||
!keys(input, ['contents']) ||
!validChatContents(input.contents, null)
)
return null;
if (!pairedContents(input.contents)) return null;
const overpass = overpassConfigured();
return {
endpoint: `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`,
headers: (key) => ({ 'x-goog-api-key': key }),
body: {
contents: input.contents,
systemInstruction: {
parts: [
{ text: textInstructions(annotationGuidance, { overpass }) },
],
},
tools: [
{
functionDeclarations: offeredTools(tools, { overpass }).map(
({ name, description, parameters }) => ({
name,
description,
parametersJsonSchema: parameters,
}),
),
},
],
generationConfig: {
responseModalities: ['TEXT'],
maxOutputTokens: 4096,
thinkingConfig: { thinkingLevel: 'LOW' },
},
},
};
},
parse(data) {
const candidate = data?.candidates?.[0];
const content = candidate?.content;
if (
!content ||
!validChatContents([content], null) ||
content.role !== 'model'
)
return null;
const calls = content.parts
.filter((part) => part.functionCall)
.map((part) => part.functionCall);
return {
content,
finishReason: candidate.finishReason,
invalidCalls: invalidCalls(
calls,
offeredTools(tools, { overpass: overpassConfigured() }),
),
};
},
});
}
// Never forward an orphan result or an unfinished batch in browser history.
function pairedContents(contents) {
let pending = [];
for (const content of contents) {
const calls = content.parts
.filter((part) => part.functionCall)
.map((part) => part.functionCall);
const results = content.parts
.filter((part) => part.functionResponse)
.map((part) => part.functionResponse);
if (pending.length) {
if (
calls.length ||
results.length !== pending.length ||
results.some(
(result, i) =>
result.name !== pending[i].name || result.id !== pending[i].id,
)
)
return false;
pending = [];
} else if (results.length) return false;
if (calls.length) pending = calls;
}
return !pending.length;
}
+2
View File
@@ -28,6 +28,7 @@ import { weatherProxy } from './weather.js';
import { firePerimetersProxy } from './firePerimeters.js';
import { gnssIntegrityProxy } from './gnssIntegrity.js';
import { ghostMapsProxy } from './ghostMaps.js';
import { droneAirspaceProxy } from './droneAirspace.js';
import { cycloneProxy } from './cyclones.js';
import { windProxy } from './wind.js';
@@ -79,6 +80,7 @@ function localProviderPlugins({
firePerimetersProxy(),
gnssIntegrityProxy(),
ghostMapsProxy(),
droneAirspaceProxy(),
keySetupEndpoint(),
];
}
+5
View File
@@ -1,3 +1,4 @@
import { createOpenAiChatHandler } from './openai/chat.js';
import { defaultSourceRoot } from './common/source-root.js';
import { handleHudSummary } from './openai/hud-summary.js';
import { createDebugLogHandler } from './openai/debug-log.js';
@@ -22,6 +23,10 @@ function openAiRealtimeProxy({
} = {}) {
const oauthLogin = createCodexOAuthLogin(realtime);
function install(middlewares) {
middlewares.use(
'/api/openai/chat',
createOpenAiChatHandler({ ...realtime, annotationGuidance }),
);
// Cost-bearing and log endpoints refuse cross-site browser requests
// (see server/providers/common/same-site.js and SECURITY.md).
middlewares.use('/api/openai/hud-summary', sameSiteGated(handleHudSummary));
+169
View File
@@ -0,0 +1,169 @@
import { createTextChatHandler } from '../common/textChat.js';
import { preferCodexOAuth } from './codex-auth.js';
import { OPENAI_DEFAULT_PER_MIN } from './rate-limit.js';
import { invalidCalls } from '../common/textTools.js';
import { textInstructions } from '../common/textInstructions.js';
import { GEV_REALTIME_TOOLS, offeredTools } from './tools.js';
import { resolveOverpassUpstreams } from '../overpass/constants.js';
const object = (value) =>
value && typeof value === 'object' && !Array.isArray(value);
const keys = (value, allowed) =>
Object.keys(value).every((key) => allowed.includes(key));
const callShape = (call) =>
object(call) &&
keys(call, ['id', 'type', 'function']) &&
typeof call.id === 'string' &&
call.id.length > 0 &&
call.id.length <= 1024 &&
call.type === 'function' &&
object(call.function) &&
keys(call.function, ['name', 'arguments']) &&
typeof call.function.name === 'string' &&
call.function.name.length <= 128 &&
typeof call.function.arguments === 'string';
function assistantShape(message) {
return (
object(message) &&
keys(message, ['role', 'content', 'tool_calls']) &&
message.role === 'assistant' &&
(message.content == null || typeof message.content === 'string') &&
(message.tool_calls === undefined ||
(Array.isArray(message.tool_calls) &&
message.tool_calls.length > 0 &&
message.tool_calls.length <= 32 &&
message.tool_calls.every(callShape) &&
new Set(message.tool_calls.map((call) => call.id)).size ===
message.tool_calls.length))
);
}
function validMessages(messages) {
if (!Array.isArray(messages) || !messages.length || messages.length > 96)
return false;
let pending = [];
for (const message of messages) {
if (!object(message)) return false;
if (pending.length) {
if (
!keys(message, ['role', 'content', 'tool_call_id']) ||
message.role !== 'tool' ||
typeof message.content !== 'string' ||
message.tool_call_id !== pending.shift()
)
return false;
} else if (message.role === 'user') {
if (
!keys(message, ['role', 'content']) ||
typeof message.content !== 'string'
)
return false;
} else if (assistantShape(message)) {
pending = (message.tool_calls || []).map((call) => call.id);
} else return false;
}
return !pending.length;
}
export function createOpenAiChatHandler({
annotationGuidance,
tools = GEV_REALTIME_TOOLS,
// osm_query is offered, and the prompt names it, only with Overpass, as
// for voice.
overpassConfigured = () => resolveOverpassUpstreams().length > 0,
resolveApiKey = () => process.env.OPENAI_API_KEY,
resolveChatModel = () => process.env.OPENAI_CHAT_MODEL || 'gpt-4.1-mini',
resolveProviderRateLimit = () => process.env.GEV_RATELIMIT_OPENAI_PER_MIN,
environment = process.env,
...options
} = {}) {
return createTextChatHandler({
...options,
provider: 'openai',
resolveApiKey,
resolveProviderRateLimit,
providerPerMin: OPENAI_DEFAULT_PER_MIN,
// Chat can only bill the API key, which this operator lock forbids.
refuse() {
let error =
'ChatGPT OAuth is required by GEV_PREFER_CODEX_OAUTH, and typed OpenAI chat needs an API key.';
try {
if (!preferCodexOAuth(environment)) return null;
} catch (invalid) {
error = invalid.message;
}
return { error, code: 'CODEX_OAUTH_REQUIRED' };
},
prepare(input) {
const model = String(resolveChatModel()).trim();
if (
!/^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,119}$/.test(model) ||
/realtime|audio|tts/i.test(model)
)
return { errorStatus: 503 };
if (
!object(input) ||
!keys(input, ['messages']) ||
!validMessages(input.messages)
)
return null;
const overpass = overpassConfigured();
return {
endpoint: 'https://api.openai.com/v1/chat/completions',
headers: (key) => ({ Authorization: `Bearer ${key}` }),
body: {
model,
n: 1,
stream: false,
store: false,
max_completion_tokens: 4096,
messages: [
{
role: 'system',
content: textInstructions(annotationGuidance, { overpass }),
},
...input.messages,
],
tools: offeredTools(tools, { overpass }).map(
({ name, description, parameters }) => ({
type: 'function',
function: { name, description, parameters, strict: false },
}),
),
},
};
},
parse(data) {
const choice = data?.choices?.[0];
const raw = choice?.message;
if (!raw) return null;
const content = {
role: raw.role,
content: raw.content ?? null,
...(raw.tool_calls?.length ? { tool_calls: raw.tool_calls } : {}),
};
if (
!assistantShape(content) ||
(!content.content && !content.tool_calls?.length)
)
return null;
const calls = (content.tool_calls || []).map((call) => {
try {
return {
name: call.function.name,
args: JSON.parse(call.function.arguments),
};
} catch {
return { name: call.function.name, parseError: true };
}
});
return {
content,
finishReason: choice.finish_reason,
invalidCalls: invalidCalls(
calls,
offeredTools(tools, { overpass: overpassConfigured() }),
),
};
},
});
}
+10 -6
View File
@@ -2,11 +2,11 @@ const DEFAULT_ANNOTATION_GUIDANCE =
'Put related places in one annotate_map call. Set flyTo true only when the user is not already looking at the place (off-screen marks auto-frame, so when unsure leave it false). Do not say you are drawing or annotating — talk about the places while the marks appear. Marks ACCUMULATE AND PERSIST across flights and topics so the user can build up the map: never pass clearPrevious, and call clear_annotations ONLY when the user EXPLICITLY asks to clear or reset the map.';
/**
* The Realtime session instructions. `overpass` says an operator configured
* Overpass, which is what offers osm_query (see tools.js); without it the
* prompt never mentions the tool.
* The Realtime session directives, one per line. `overpass` says an operator
* configured Overpass, which is what offers osm_query (see tools.js); without
* it the prompt never mentions the tool.
*/
function realtimeInstructions(
function realtimeDirectives(
annotationGuidance = DEFAULT_ANNOTATION_GUIDANCE,
{ overpass = false } = {},
) {
@@ -74,7 +74,11 @@ function realtimeInstructions(
annotationGuidance,
'annotate_map results: work any say line in naturally (places not found, a straight-line fallback with no travel time, or a full map — then ask before clearing). outlinePending means the boundary is still being traced: never claim it is drawn. A later map_annotation_outline system item reports each outcome; use it to quietly confirm or correct yourself.',
'ROUTES vs DISTANCES: "walking/driving route from A to B" → type=route with the ordered points and the mode; the app draws the street path and reports distance and time you can read aloud. "How far is X from Y" or "X is next to Y" → type=arrow (straight-line distance), never route.',
].join('\n');
];
}
export { realtimeInstructions };
function realtimeInstructions(annotationGuidance, options) {
return realtimeDirectives(annotationGuidance, options).join('\n');
}
export { realtimeInstructions, realtimeDirectives };