Files
gods-eye-view/server/providers/openai/hud-summary.js
T
Pedro Lobato 9e71081a5a fix(server): stop relaying upstream and JS error text to the client
Ported onto the split provider layout: the routes now live in
server/providers/openai/{hud-summary,realtime}.js and the camera health
entry in server/providers/cctv.js.

The routes #263 names — celestrak, terrain, adsbdb — were sanitized by
#289. The same defect had moved:

- /api/openai/hud-summary relayed `data.error.message` verbatim on a
  non-ok upstream. That is OpenAI's own error text, carrying request ids,
  organization hints and quota wording. Its catch echoed the JS error too.
- /api/realtime/token echoed the JS error when minting failed, which for a
  network fault is a resolver message naming the upstream host.
- cctvProxy stored the raw error as a camera's health `message`. This is
  the least obvious of the three and the reason the PR is worth its size:
  GET /api/cctv/health serializes `message`, and src/layers/cctv/frames.js
  renders it as the camera's status label, so the detail reached the
  screen through a different door than the sanitized 502 beside it.

Logs name the failure and the upstream status without the text, matching
the Launch Library and terrain paths; these lines get pasted into issues.

Coverage extends the existing harnesses rather than adding files —
localServices.test.mjs drives both OpenAI routes over a mocked upstream,
mediaProviders.test.mjs drives a failing media fetch and then reads
/health. All three assertions fail on the previous implementation.

Refs #263. Does not touch /api/realtime/debug-log's identical echo; that
route is the subject of the #23 PR, so the two do not overlap a line.
2026-09-14 21:24:05 -05:00

104 lines
3.7 KiB
JavaScript

import { keylessHudSummaryResponse } from '../../../src/hudSummaryResponse.js';
import { enforceOptInRateLimit, openAiRateLimiter } from './rate-limit.js';
import { readRequestBody } from '../common/request.js';
import { OPENAI_HUD_SUMMARY_MODEL_DEFAULT } from './constants.js';
function extractOpenAiResponseText(data) {
if (typeof data?.output_text === 'string' && data.output_text.trim()) {
return data.output_text.trim();
}
if (!Array.isArray(data?.output)) return '';
return data.output
.flatMap((item) => (Array.isArray(item?.content) ? item.content : []))
.map((part) => part?.text || part?.output_text || '')
.join(' ')
.trim();
}
function toFiveWordHudSummary(value) {
return String(value || '')
.replace(/[^\p{L}\p{N}\s-]/gu, ' ')
.trim()
.split(/\s+/)
.filter(Boolean)
.slice(0, 5)
.join(' ');
}
async function handleHudSummary(req, res) {
if (req.method !== 'POST') {
res.statusCode = 405;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ error: 'Method not allowed' }));
return;
}
const apiKey = process.env.OPENAI_API_KEY;
const keyless = keylessHudSummaryResponse(apiKey);
if (keyless) {
res.statusCode = keyless.statusCode;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
res.end(JSON.stringify(keyless.payload));
return;
}
// Opt-in per-IP throttle (GEV_RATELIMIT_OPENAI_PER_MIN). Keyless HUD
// fallback has no provider cost and resolves above without consuming a
// paid-endpoint quota slot.
if (!enforceOptInRateLimit(openAiRateLimiter(), req, res)) return;
try {
const body = await readRequestBody(req, 64 * 1024);
const context = JSON.parse(body || '{}');
const response = await fetch('https://api.openai.com/v1/responses', {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model:
process.env.OPENAI_HUD_SUMMARY_MODEL ||
OPENAI_HUD_SUMMARY_MODEL_DEFAULT,
instructions: [
"Write one concise intelligence-HUD summary for God's Eye View.",
'Use only the supplied place, street, nearby-place, and enabled-layer text labels.',
'Prefer the clearest named place and include a relevant enabled layer only when useful.',
'Do not infer from coordinates or invent a place.',
'Output exactly five words with no title, punctuation, markdown, or introductory phrase.',
].join(' '),
input: JSON.stringify(context),
reasoning: { effort: 'minimal' },
max_output_tokens: 100,
}),
});
const data = await response.json().catch(() => ({}));
const summary = toFiveWordHudSummary(extractOpenAiResponseText(data));
res.statusCode = response.ok && summary ? 200 : response.status || 502;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
if (!response.ok)
console.warn(`[hud-summary] upstream HTTP ${response.status}`);
res.end(
JSON.stringify({
summary: summary || null,
// Never relay `data.error.message`: that is OpenAI's own wording, and
// it carries request ids, organization hints and quota phrasing.
error: response.ok ? null : 'OpenAI HUD summary request failed',
}),
);
} catch {
console.warn('[hud-summary] request failed');
res.statusCode = 502;
res.setHeader('Content-Type', 'application/json');
res.end(
JSON.stringify({
error: 'OpenAI HUD summary request failed',
}),
);
}
}
export { handleHudSummary };