mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
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:
commit
bf73a8a1e1
128 files changed
+14952
-173
No files matched your search
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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() }),
|
||||
),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
Reference in new issue
Block a user