Compare commits

..
14 changed files with 66 additions and 78 deletions

No files matched your search

@@ -0,0 +1,4 @@
- Without `OPENAI_API_KEY`, the mic now says the key is missing and to add it in
Provider Settings, instead of showing the raw server error with a hint to
check microphone permissions. `/api/realtime/token` returns
`code: "API_KEY_MISSING"` with its 503 (asturksever, #1599).
@@ -1,5 +0,0 @@
- The CCTV Street View fallback serves only catalog cameras, at their catalog
position, heading, field of view and pitch, and ignores coordinates a
request supplies. It spends from the per-client Google budget
(`GEV_RATELIMIT_GOOGLE_PER_MIN`, default 120), so the Google key can no
longer be used as an unmetered Street View service (asturksever, #1602).
+1 -4
View File
@@ -1276,10 +1276,7 @@ and follows redirects only on the camera's origin. Catalog image URLs, and
redirects on those hosts, are pinned by parsed URL to the agency's image
directory. Every `/api/cctv` route refuses cross-site browser requests but
accepts forwarding headers, so cameras work behind a reverse proxy; a forwarded
request skips the key-bearing Street View fallback and gets the placeholder. The
Street View fallback serves only catalog cameras, at the catalog position,
heading, field of view and pitch, never coordinates a request supplies, and it
shares the per-client Google budget (`GEV_RATELIMIT_GOOGLE_PER_MIN`, default 120).
request skips the key-bearing Street View fallback and gets the placeholder.
The CCTV layer draws only the cameras on or near the screen. Zoomed out, it
keeps one camera per ground cell sized to the view, preferring a healthy feed,
+1 -3
View File
@@ -377,9 +377,7 @@
"src/data/cctvFootprint.js",
"src/sources/cctvTypes.js",
"src/sources/httpBody.js",
"src/i18n/marker.js",
"server/providers/common/rate-limit.js",
"src/sources/rateLimit.js"
"src/i18n/marker.js"
],
"external": []
},
+16 -32
View File
@@ -19,22 +19,7 @@ import { admitSameSiteKeyless } from './common/same-site.js';
import { PROXY_SIGNALS } from '../../src/localRequestGate.mjs';
import { createHlsPuller } from './cctv/stream.js';
import { googleServerApiKey } from './places/google-key.js';
import { clientKey, makeCostRateLimiter } from './common/rate-limit.js';
export { CCTV_FRAME_FETCH_TIMEOUT_MS, fetchCctvImageFromUpstream };
let _streetViewRateLimiter;
/**
* Per-IP budget for the Street View fallback, on the same setting as the
* other Google routes (GEV_RATELIMIT_GOOGLE_PER_MIN; 0 disables it).
*/
function streetViewRateLimiter() {
if (_streetViewRateLimiter === undefined)
_streetViewRateLimiter = makeCostRateLimiter(
process.env.GEV_RATELIMIT_GOOGLE_PER_MIN,
120,
);
return _streetViewRateLimiter;
}
/**
* Vite plugin: CCTV camera proxy with source registry, frame/media serving,
* fallback chain (upstream -> Street View -> synthetic SVG), and health tracking.
@@ -524,6 +509,13 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
const source = sourceById.get(cameraId);
const label = url.searchParams.get('label') || source?.name || cameraId;
const city = url.searchParams.get('city') || source?.city || '';
const lat = Number(url.searchParams.get('lat') || source?.lat);
const lon = Number(url.searchParams.get('lon') || source?.lon);
const heading = Number(
url.searchParams.get('heading') || source?.headingDeg,
);
const fov = Number(url.searchParams.get('fov') || source?.fovDeg);
const pitch = Number(url.searchParams.get('pitch') || source?.pitchDeg);
// Only use server-registered upstream URLs — never accept client-supplied URLs
// (prevents SSRF via ?upstream= query parameter)
@@ -557,26 +549,18 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
// request (whose client this server cannot see) gets the placeholder,
// as if no key were configured. Any forwarding header counts, even an
// empty one.
// It is also only for catalog cameras, framed from the catalog (never
// a spot the query names), within the per-IP Google budget.
const forwarded = PROXY_SIGNALS.some(
(name) => req.headers?.[name] !== undefined,
);
const googleBudget = googleServerApiKey()
? streetViewRateLimiter()
: null;
const sv =
forwarded ||
!source ||
(googleBudget && !googleBudget(clientKey(req)))
? null
: await streetViewFallback({
lat: Number(source.lat),
lon: Number(source.lon),
heading: Number(source.headingDeg),
fov: Number(source.fovDeg),
pitch: Number(source.pitchDeg ?? -10),
});
const sv = forwarded
? null
: await streetViewFallback({
lat,
lon,
heading,
fov,
pitch,
});
if (sv?.ok) {
setHealth(cameraId, {
status: 'degraded',
+6 -1
View File
@@ -224,7 +224,12 @@ function createRealtimeTokenHandler({
if (!bearer) {
res.statusCode = 503;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ error: 'OPENAI_API_KEY is not set' }));
res.end(
JSON.stringify({
error: 'OPENAI_API_KEY is not set',
code: 'API_KEY_MISSING',
}),
);
return;
}
}
-27
View File
@@ -528,30 +528,3 @@ test('a forwarded same-origin request reaches every CCTV route but not the Stree
assert.equal(direct.body, 'street-view');
assert.equal(streetViewCalls(), 1);
});
test('the Street View fallback frames catalog cameras only, from the catalog position', async (t) => {
const app = mount(t, ({ url }) =>
url.startsWith('https://maps.googleapis.com/')
? trackedUpstream({
headers: { 'content-type': 'image/jpeg' },
body: 'street-view',
}).response
: upstreamResponse({ status: 503 }),
);
process.env.GOOGLE_MAPS_SERVER_API_KEY = 'fixture-key';
const streetView = () =>
app.requests.filter(({ url }) =>
url.startsWith('https://maps.googleapis.com/'),
);
const unknown = await app.call('/frame/not-a-camera?lat=48.85&lon=2.29');
assert.equal(unknown.headers['X-CCTV-Source'], 'synthetic');
assert.deepEqual(streetView(), [], 'no Google call for an unknown id');
const steered = await app.call(
`/frame/${STILL.id}?lat=48.85&lon=2.29&heading=90`,
);
assert.equal(steered.headers['X-CCTV-Source'], 'streetview');
const location = new URL(streetView()[0].url).searchParams.get('location');
assert.equal(location, `${STILL.lat},${STILL.lon}`);
});
+4 -1
View File
@@ -150,7 +150,10 @@ test('the installed keyless HUD route stays successful after the voice quota is
const firstToken = await invokeRoute(token);
const secondToken = await invokeRoute(token);
assert.equal(firstToken.statusCode, 503);
assert.deepEqual(firstToken.body, { error: 'OPENAI_API_KEY is not set' });
assert.deepEqual(firstToken.body, {
error: 'OPENAI_API_KEY is not set',
code: 'API_KEY_MISSING',
});
assert.equal(secondToken.statusCode, 429);
for (let attempt = 0; attempt < 2; attempt += 1) {
+1 -1
View File
@@ -166,7 +166,7 @@ const frameStyle = (panel) => ({
<button class="gev-voice-error-dismiss" type="button" @click="controls?.dismissError()">{{ t('DISMISS') }}</button>
</div>
<div id="gev-voice-error-detail">{{ view.error.text }}</div>
<div class="gev-voice-error-hint" :hidden="view.error.typed">{{ t('Check microphone permission and network access, then try again.') }}</div>
<div class="gev-voice-error-hint" :hidden="view.error.typed || view.error.hint === false">{{ t('Check microphone permission and network access, then try again.') }}</div>
</div>
</div>
</template>
+1 -1
View File
@@ -107,7 +107,7 @@ function initialState(panel) {
t('Hold Space to speak · tap Space to activate focused controls'),
},
// A typed chat error reads as chat's, without the microphone hint.
error: { text: '', typed: false },
error: { text: '', typed: false, hint: true },
// Connection and turn state beside the selected mode's indicator.
phase: { text: '' },
voicePanel: {
+3 -2
View File
@@ -39,8 +39,9 @@ export function createRealtimeBackend({
if (!response.ok) {
const reason =
typeof data?.error === 'string' ? data.error : data?.error?.message;
throw new Error(
reason || `Realtime token failed: HTTP ${response.status}`,
throw Object.assign(
new Error(reason || `Realtime token failed: HTTP ${response.status}`),
typeof data?.code === 'string' ? { code: data.code } : {},
);
}
const token =
+14
View File
@@ -143,3 +143,17 @@ test('controller lifetime stops pending transport and releases resources through
assert.equal(controller.stream, null);
assert.equal(controller.isActive(), false);
});
test('a missing server key keeps its code, so the UI can point to Provider Settings', async () => {
const backend = createRealtimeBackend({
tokenTransport: async () =>
Response.json(
{ error: 'OPENAI_API_KEY is not set', code: 'API_KEY_MISSING' },
{ status: 503 },
),
});
await assert.rejects(backend.requestToken(), {
message: 'OPENAI_API_KEY is not set',
code: 'API_KEY_MISSING',
});
});
+4 -1
View File
@@ -18,7 +18,7 @@ import {
} from './pointerContext.js';
import { shouldPauseRadioForVoice } from './realtimeProtocol.js';
import { postDebugLog } from './realtimeDiagnostics.js';
import { missingVoiceKeyText, postDebugLog } from './realtimeDiagnostics.js';
export {
readStoredVoiceTier,
@@ -436,6 +436,9 @@ export class GevRealtimeController extends RealtimeFacade {
status === 'error'
? resolvedDetail() || t('Voice session could not be started.')
: '',
// "Check microphone permission" is wrong advice for a missing key.
hint: () =>
!(status === 'error' && resolvedDetail() === missingVoiceKeyText()),
});
if (status === 'idle' || status === 'connecting' || status === 'error') {
this.setVoiceSpeaker('idle');
+11
View File
@@ -1,3 +1,11 @@
import { t } from '../i18n/index.js';
/** The voice error when the server has no OpenAI key: where to add it. */
export const missingVoiceKeyText = () =>
t('Needs {envVars} — add it in Provider Settings', {
envVars: 'OPENAI_API_KEY',
});
const ERROR_LOG_LIMIT = 30;
const ERROR_STORAGE_KEY = 'gev-realtime-errors';
@@ -83,6 +91,7 @@ function createErrorRecord(source, error, extra = {}) {
String(error?.message || '').trim() ||
'No browser error message supplied',
errorDetail: rtcError?.errorDetail || null,
code: typeof rtcError?.code === 'string' ? rtcError.code : null,
sctpCauseCode: rtcError?.sctpCauseCode ?? null,
receivedAlert: rtcError?.receivedAlert ?? null,
sentAlert: rtcError?.sentAlert ?? null,
@@ -91,6 +100,8 @@ function createErrorRecord(source, error, extra = {}) {
}
function formatErrorForDisplay(record) {
// A missing key is a setup step, not a connection fault: say where to add it.
if (record.code === 'API_KEY_MISSING') return missingVoiceKeyText();
const primary = [record.source, record.message].filter(Boolean).join(': ');
const state = [
record.errorDetail && `detail=${record.errorDetail}`,