mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge pull request #448 from bilawalsidhu/refactor/voice-backend-services
Separate voice controls and configurable Realtime connections
This commit is contained in:
21 files changed
+3129
-2793
No files matched your search
@@ -1,5 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## Voice component boundaries
|
||||
|
||||
- Separate voice controls, Realtime connection requests and the action runner.
|
||||
- Allow compatible endpoints and server-selected models through construction options.
|
||||
- Cancel pending token/SDP requests on Stop or teardown and reject expired secrets.
|
||||
|
||||
|
||||
## Configurable geospatial services
|
||||
|
||||
- Compose geocoding, place context and routes through independent providers.
|
||||
|
||||
@@ -149,3 +149,26 @@ selects compatible OSRM base URLs while preserving coordinate/span limits.
|
||||
`createRegionalPlaceProvider({ endpoint, requestJson })` supplies the existing
|
||||
serialized Nominatim implementation. Configuration is per instance, including
|
||||
routing and regional caches. Request parameters cannot override these endpoints.
|
||||
|
||||
### Voice connections and controls
|
||||
|
||||
Voice controls accept an action runner and a controller factory through
|
||||
`createVoiceCommands` (`./voice/commands`). The default Realtime controller owns
|
||||
microphone tracks, playback, push-to-talk, tool cancellation and radio handoff.
|
||||
Its backend supplies `requestToken({ tier, signal })` and
|
||||
`negotiate({ offerSdp, credential, signal })`. The Realtime-compatible adapter
|
||||
(`./voice/realtime-backend`) accepts separate token and connection transports
|
||||
and endpoints. Only the short-lived client secret reaches the SDP endpoint.
|
||||
Stopping or ending the application lifetime aborts connection requests and
|
||||
rejects delayed responses; a new start requests a fresh secret. Secret expiry
|
||||
limits connection creation and does not describe the connected session lifetime.
|
||||
|
||||
`createStandaloneApplication({ voice })` passes these construction options to
|
||||
the controls. An incompatible protocol needs a separate controller adapter;
|
||||
changing an endpoint alone does not translate protocol messages. No alternate
|
||||
model is bundled by this extraction. The Node Realtime provider accepts
|
||||
`realtime: { endpoint, models: { standard, mini }, resolveApiKey, fetchImpl }`;
|
||||
existing environment variables remain the default configuration. Model choices
|
||||
come from server configuration. Keep secret keys in the server adapter.
|
||||
Tool schemas, model defaults and cost estimates are unchanged. Unknown model
|
||||
IDs retain the existing conservative estimate until their rates are registered.
|
||||
@@ -1,5 +1,12 @@
|
||||
# God's Eye View Current State
|
||||
|
||||
Voice controls compose a supplied action runner and connection controller.
|
||||
Realtime token and SDP requests live in a configurable backend, with independent
|
||||
transports and cancellation through response parsing. Stop and application
|
||||
teardown abort pending connections; reconnect requests a new client secret.
|
||||
Microphone, radio handoff, tool schemas and default model behavior are preserved.
|
||||
|
||||
|
||||
Geospatial lookups are composed through `src/search`: forward/reverse geocoding,
|
||||
text/nearby search and routing use configurable providers/endpoints. Annotation,
|
||||
HUD and voice consumers share the configured service. Existing Google/Photon
|
||||
|
||||
+6
-1
@@ -167,6 +167,11 @@
|
||||
"./maps/imagery": "./src/maps/imagery.js",
|
||||
"./maps/terrain": "./src/maps/terrain.js",
|
||||
"./maps/3d": "./src/maps/google3d.js",
|
||||
"./maps/defaults": "./src/maps/defaultSources.js"
|
||||
"./maps/defaults": "./src/maps/defaultSources.js",
|
||||
"./voice/controller": "./src/voice/realtimeController.js",
|
||||
"./voice/control": "./src/voice/control.js",
|
||||
"./voice/realtime-backend": "./src/voice/realtimeBackend.js",
|
||||
"./voice/commands": "./src/voice/commands.js",
|
||||
"./voice/cost": "./src/voice/voiceCost.js"
|
||||
}
|
||||
}
|
||||
@@ -536,5 +536,10 @@
|
||||
"src/search/geospatial.js",
|
||||
"src/search/http.js",
|
||||
"src/search/defaults.js",
|
||||
"src/search/geospatial.test.mjs"
|
||||
"src/search/geospatial.test.mjs",
|
||||
"src/voice/commands.js",
|
||||
"src/voice/control.js",
|
||||
"src/voice/realtimeBackend.js",
|
||||
"src/voice/realtimeBackend.test.mjs",
|
||||
"src/voice/layerSummary.js"
|
||||
]
|
||||
@@ -1003,5 +1003,23 @@
|
||||
"src/keySetupCore.mjs"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
"voice-connection": {
|
||||
"exports": [
|
||||
"./voice/controller",
|
||||
"./voice/control",
|
||||
"./voice/realtime-backend",
|
||||
"./voice/commands",
|
||||
"./voice/cost"
|
||||
],
|
||||
"modules": [
|
||||
"src/voice/realtimeController.js",
|
||||
"src/voice/realtimeBackend.js",
|
||||
"src/voice/control.js",
|
||||
"src/voice/layerSummary.js",
|
||||
"src/voice/voiceCost.js",
|
||||
"src/voice/commands.js"
|
||||
],
|
||||
"external": []
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { createRealtimeTokenHandler } from './openai/realtime.js';
|
||||
function openAiRealtimeProxy({
|
||||
sourceRoot = defaultSourceRoot,
|
||||
annotationGuidance,
|
||||
realtime = {},
|
||||
} = {}) {
|
||||
function install(middlewares) {
|
||||
middlewares.use('/api/openai/hud-summary', handleHudSummary);
|
||||
@@ -23,7 +24,7 @@ function openAiRealtimeProxy({
|
||||
|
||||
middlewares.use(
|
||||
'/api/realtime/token',
|
||||
createRealtimeTokenHandler({ annotationGuidance }),
|
||||
createRealtimeTokenHandler({ ...realtime, annotationGuidance }),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,8 +14,15 @@ import {
|
||||
import { realtimeInstructions } from './instructions.js';
|
||||
import { GEV_REALTIME_TOOLS } from './tools.js';
|
||||
|
||||
function createRealtimeTokenHandler({ annotationGuidance } = {}) {
|
||||
function createRealtimeTokenHandler({
|
||||
annotationGuidance,
|
||||
endpoint = 'https://api.openai.com/v1/realtime/client_secrets',
|
||||
fetchImpl = (...args) => fetch(...args),
|
||||
resolveApiKey = () => process.env.OPENAI_API_KEY,
|
||||
models = {},
|
||||
} = {}) {
|
||||
return async (req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
if (req.method !== 'GET' && req.method !== 'POST') {
|
||||
res.statusCode = 405;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
@@ -26,7 +33,7 @@ function createRealtimeTokenHandler({ annotationGuidance } = {}) {
|
||||
// Opt-in per-IP throttle (GEV_RATELIMIT_OPENAI_PER_MIN). No-op when unset.
|
||||
if (!enforceOptInRateLimit(openAiRateLimiter(), req, res)) return;
|
||||
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
const apiKey = resolveApiKey();
|
||||
if (!apiKey) {
|
||||
res.statusCode = 503;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
@@ -52,9 +59,12 @@ function createRealtimeTokenHandler({ annotationGuidance } = {}) {
|
||||
const tier = resolveVoiceModel(requestedTier).tier;
|
||||
const model =
|
||||
tier === 'mini'
|
||||
? process.env.OPENAI_REALTIME_MODEL_MINI ||
|
||||
? models.mini ||
|
||||
process.env.OPENAI_REALTIME_MODEL_MINI ||
|
||||
OPENAI_REALTIME_MODEL_MINI_DEFAULT
|
||||
: process.env.OPENAI_REALTIME_MODEL || OPENAI_REALTIME_MODEL_DEFAULT;
|
||||
: models.standard ||
|
||||
process.env.OPENAI_REALTIME_MODEL ||
|
||||
OPENAI_REALTIME_MODEL_DEFAULT;
|
||||
const voice =
|
||||
process.env.OPENAI_REALTIME_VOICE || OPENAI_REALTIME_VOICE_DEFAULT;
|
||||
const effort =
|
||||
@@ -109,18 +119,17 @@ function createRealtimeTokenHandler({ annotationGuidance } = {}) {
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
'https://api.openai.com/v1/realtime/client_secrets',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'OpenAI-Safety-Identifier': 'gev-local-dev',
|
||||
},
|
||||
body: JSON.stringify(sessionConfig),
|
||||
const response = await fetchImpl(endpoint, {
|
||||
method: 'POST',
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'OpenAI-Safety-Identifier': 'gev-local-dev',
|
||||
},
|
||||
);
|
||||
body: JSON.stringify(sessionConfig),
|
||||
});
|
||||
const body = await response.text();
|
||||
res.statusCode = response.status;
|
||||
res.setHeader(
|
||||
|
||||
@@ -15,7 +15,7 @@ const radio = ['playback', 'interaction'].map(name =>
|
||||
readFileSync(new URL(`./layers/radio/${name}.js`, import.meta.url), 'utf8')
|
||||
).join('\n').replace(/layerState\.|parts\.\w+\./g, '');
|
||||
const rocketLaunches = readLayerSource(new URL('./data/rocketLaunches.js', import.meta.url), 'utf8');
|
||||
const realtime = readFileSync(new URL('./voice/gevRealtime.js', import.meta.url), 'utf8');
|
||||
const realtime = readFileSync(new URL('./voice/realtimeController.js', import.meta.url), 'utf8');
|
||||
const voice = ['tools', 'instructions'].map(name => readFileSync(new URL(`../server/providers/openai/${name}.js`, import.meta.url), 'utf8')).join('\n');
|
||||
const css = readStylesheet(new URL('../style.css', import.meta.url));
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ export function createStandaloneApplication({
|
||||
googleApiKey,
|
||||
cesiumToken,
|
||||
geospatial = {},
|
||||
voice = {},
|
||||
allowQaRegistration = false,
|
||||
}) {
|
||||
if (constructed)
|
||||
@@ -40,6 +41,6 @@ export function createStandaloneApplication({
|
||||
createData: (context) =>
|
||||
createStandaloneData({ ...context, allowQaRegistration }),
|
||||
createTools: (context) =>
|
||||
createStandaloneTools({ ...context, loadingScreen, placeSearch }),
|
||||
createStandaloneTools({ ...context, loadingScreen, placeSearch, voice }),
|
||||
});
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export function createStandaloneTools({
|
||||
data,
|
||||
loadingScreen,
|
||||
placeSearch,
|
||||
voice = {},
|
||||
signal,
|
||||
defer,
|
||||
}) {
|
||||
@@ -100,6 +101,8 @@ export function createStandaloneTools({
|
||||
if (window.__godsEyeView === debug) delete window.__godsEyeView;
|
||||
});
|
||||
const voiceCommands = initGevVoiceCommands({
|
||||
...voice,
|
||||
signal,
|
||||
placeSearch,
|
||||
viewer,
|
||||
styleManager,
|
||||
|
||||
@@ -255,3 +255,31 @@ test('key setup writes only the supplied application root, retains request guard
|
||||
assert.equal(saved.body.includes('sk-fixture-only-not-a-real-key'), false);
|
||||
assert.equal(existsSync(path.join(untouched, '.env')), false);
|
||||
});
|
||||
|
||||
test('Realtime service configuration selects compatible endpoint/model without forwarding request model IDs or keys', async () => {
|
||||
const handler = install(
|
||||
openAiRealtimeProxy({
|
||||
realtime: {
|
||||
endpoint: 'https://voice.example/client-secrets',
|
||||
models: { standard: 'configured-model' },
|
||||
resolveApiKey: () => 'server-fixture',
|
||||
fetchImpl: async (url, options) => {
|
||||
assert.equal(url, 'https://voice.example/client-secrets');
|
||||
assert.equal(options.redirect, 'error');
|
||||
assert.equal(options.headers.Authorization, 'Bearer server-fixture');
|
||||
const payload = JSON.parse(options.body);
|
||||
assert.equal(payload.session.model, 'configured-model');
|
||||
assert.deepEqual(payload.session.tools, GEV_REALTIME_TOOLS);
|
||||
return Response.json({ value: 'short-lived-fixture' });
|
||||
},
|
||||
},
|
||||
}),
|
||||
).get('/api/realtime/token');
|
||||
const response = await request(handler, {
|
||||
url: '/?tier=arbitrary-model&model=other',
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers['x-gev-voice-model'], 'configured-model');
|
||||
assert.equal(response.headers['cache-control'], 'no-store');
|
||||
assert.doesNotMatch(response.body, /server-fixture|voice\.example/);
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const voiceConfig = readFileSync(new URL('../../server/providers/openai/instructions.js', import.meta.url), 'utf8');
|
||||
const realtime = readFileSync(new URL('./gevRealtime.js', import.meta.url), 'utf8');
|
||||
const realtime = readFileSync(new URL('./realtimeController.js', import.meta.url), 'utf8');
|
||||
|
||||
test('aircraft identity narration acknowledges missing enrichment', () => {
|
||||
const start = voiceConfig.indexOf("'For \"what is this aircraft?\" answers");
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
GevRealtimeController,
|
||||
shouldIgnoreVoiceButtonClick,
|
||||
} from './realtimeController.js';
|
||||
import { createVoiceControl } from './control.js';
|
||||
|
||||
/** Bind a supplied action runner and connection controller to the voice controls. */
|
||||
export function createVoiceCommands({
|
||||
runner,
|
||||
dataManager,
|
||||
annotations = null,
|
||||
backend,
|
||||
signal,
|
||||
debugSink,
|
||||
createController = (options) => new GevRealtimeController(options),
|
||||
}) {
|
||||
if (
|
||||
window.__gevVoiceCommands &&
|
||||
typeof window.__gevVoiceCommands.stop === 'function'
|
||||
) {
|
||||
window.__gevVoiceCommands.stop({ removeUi: true });
|
||||
}
|
||||
const ui = createVoiceControl({ reset: true });
|
||||
const radioLayer = dataManager?.layers?.get('radio')?.module || null;
|
||||
const controller = createController({
|
||||
runner,
|
||||
ui,
|
||||
radioLayer,
|
||||
dataManager,
|
||||
backend,
|
||||
signal,
|
||||
debugSink,
|
||||
});
|
||||
// Deferred annotation outlines finish AFTER their tool result returned. Feed the
|
||||
// final outcome (resolved / failed) into the conversation so the model can honestly
|
||||
// confirm — or correct — what it narrated about a boundary it never saw land.
|
||||
if (annotations && typeof annotations.onOutlineEvent === 'function') {
|
||||
controller.annotationEventUnsubscribe = annotations.onOutlineEvent(
|
||||
(evt) => {
|
||||
controller.notifyMapEvent({ type: 'map_annotation_outline', ...evt });
|
||||
},
|
||||
);
|
||||
}
|
||||
controller.buttonHandler = () => {
|
||||
if (shouldIgnoreVoiceButtonClick(controller.spaceKeyHeld)) return;
|
||||
if (controller.isActive()) controller.stop();
|
||||
else controller.start({ pushToTalk: false });
|
||||
};
|
||||
ui.button.addEventListener('click', controller.buttonHandler);
|
||||
if (ui.tierButton) {
|
||||
controller.tierHandler = () => controller.toggleVoiceTier();
|
||||
ui.tierButton.addEventListener('click', controller.tierHandler);
|
||||
}
|
||||
controller.syncCostUi();
|
||||
controller.bindPushToTalkShortcut();
|
||||
window.__gevVoiceCommands = controller;
|
||||
return controller;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/** Build the voice control independently of its connection backend. */
|
||||
export function createVoiceControl({ reset = false } = {}) {
|
||||
let root = document.getElementById('gev-voice-control');
|
||||
if (root && reset) {
|
||||
root.remove();
|
||||
root = null;
|
||||
}
|
||||
if (!root) {
|
||||
root = document.createElement('div');
|
||||
root.id = 'gev-voice-control';
|
||||
root.dataset.status = 'idle';
|
||||
root.dataset.speaker = 'idle';
|
||||
root.innerHTML = `
|
||||
<div class="gev-voice-heading">
|
||||
<div class="gev-voice-kicker">AI AGENT</div>
|
||||
<div id="gev-voice-status">OFF</div>
|
||||
<div class="gev-voice-cost">
|
||||
<button id="gev-voice-tier" class="gev-voice-tier-btn" type="button" aria-pressed="false" title="Voice model tier — applies next session">STD</button>
|
||||
<span id="gev-voice-cost-value" class="gev-voice-cost-value" data-level="ok" title="Estimated session cost">~$0.00</span>
|
||||
</div>
|
||||
</div>
|
||||
<button id="gev-voice-button" type="button" aria-label="Voice control — activate to toggle voice; hold Space to speak" aria-describedby="gev-voice-help">
|
||||
<span class="gev-mic-orbit"><img src="/mic.svg" alt="" /></span>
|
||||
<span class="gev-mic-label">ON/OFF</span>
|
||||
</button>
|
||||
<div class="gev-voice-visualizer" aria-hidden="true">
|
||||
${Array.from({ length: 15 }, (_, index) => `<span style="--bar:${index}"></span>`).join('')}
|
||||
</div>
|
||||
<div class="gev-voice-readout">
|
||||
<div id="gev-voice-detail">VOICE STANDBY</div>
|
||||
</div>
|
||||
<div id="gev-voice-help" class="gev-voice-help-tray" role="tooltip">
|
||||
<span class="gev-voice-help-kicker">VOICE CONTROL</span>
|
||||
<span class="gev-voice-help-detail">Hold Space to speak · tap Space to activate focused controls</span>
|
||||
</div>
|
||||
<div class="gev-voice-error-tray" role="alert" aria-live="assertive">
|
||||
<div class="gev-voice-error-header">
|
||||
<span>VOICE SYSTEM ERROR</span>
|
||||
<button class="gev-voice-error-dismiss" type="button">DISMISS</button>
|
||||
</div>
|
||||
<div id="gev-voice-error-detail"></div>
|
||||
<div class="gev-voice-error-hint">Check microphone permission and network access, then try again.</div>
|
||||
</div>
|
||||
`;
|
||||
const commandDock = document.getElementById('command-dock');
|
||||
if (commandDock) {
|
||||
const locationBar = document.getElementById('location-bar');
|
||||
const controlPanel = document.getElementById('control-panel');
|
||||
commandDock.appendChild(root);
|
||||
if (locationBar) commandDock.insertBefore(locationBar, root);
|
||||
if (controlPanel) commandDock.appendChild(controlPanel);
|
||||
} else {
|
||||
document.body.appendChild(root);
|
||||
}
|
||||
root
|
||||
.querySelector('.gev-voice-error-dismiss')
|
||||
?.addEventListener('click', () => {
|
||||
root.classList.add('error-dismissed');
|
||||
});
|
||||
}
|
||||
return {
|
||||
root,
|
||||
button: root.querySelector('#gev-voice-button'),
|
||||
buttonLabel: root.querySelector('.gev-mic-label'),
|
||||
status: root.querySelector('#gev-voice-status'),
|
||||
detail: root.querySelector('#gev-voice-detail'),
|
||||
helpDetail: root.querySelector('.gev-voice-help-detail'),
|
||||
errorDetail: root.querySelector('#gev-voice-error-detail'),
|
||||
tierButton: root.querySelector('#gev-voice-tier'),
|
||||
costValue: root.querySelector('#gev-voice-cost-value'),
|
||||
};
|
||||
}
|
||||
+3
-40
@@ -1,3 +1,5 @@
|
||||
import { readLayerLifecycleSummary } from './layerSummary.js';
|
||||
export { readLayerLifecycleSummary } from './layerSummary.js';
|
||||
import { defaultGeospatial } from '../search/defaults.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { CITY_POIS, findPoiByName, flyToGlobeView, flyToLandmark, flyToPOI, flyToPresetLocation, GLOBE_VIEW, searchAndFlyTo } from '../locations.js';
|
||||
@@ -265,46 +267,7 @@ const VISIBLE_ENTITY_SHORTLIST = 64;
|
||||
const BASEMAP_CONTEXT_WAIT_MS = 1500;
|
||||
const viewTargetCache = new WeakMap();
|
||||
|
||||
/**
|
||||
* Read one layer's authoritative visibility and lifecycle presentation state.
|
||||
* Falls back to stable enabled/disabled state for lightweight adapters that do
|
||||
* not expose the manager lifecycle API.
|
||||
* @param {object|null} dataManager Layer manager or lightweight adapter.
|
||||
* @param {string} layerId Registered layer identifier.
|
||||
* @param {object} [options] Fallback options.
|
||||
* @param {boolean} [options.fallbackEnabled=false] Observed state when no manager read is available.
|
||||
* @returns {{enabled:boolean,lifecycleState:string,lifecycleUncertain:boolean}} Lifecycle summary.
|
||||
*/
|
||||
export function readLayerLifecycleSummary(dataManager, layerId, { fallbackEnabled = false } = {}) {
|
||||
let lifecycle = null;
|
||||
try {
|
||||
lifecycle = dataManager?.getLayerLifecycleState?.(layerId) || null;
|
||||
} catch {
|
||||
lifecycle = null;
|
||||
}
|
||||
if (lifecycle) {
|
||||
const enabled = Boolean(lifecycle.enabled);
|
||||
return {
|
||||
enabled,
|
||||
lifecycleState: lifecycle.lifecycleState || (enabled ? 'enabled' : 'disabled'),
|
||||
lifecycleUncertain: Boolean(lifecycle.uncertain ?? lifecycle.lifecycleUncertain),
|
||||
};
|
||||
}
|
||||
|
||||
let enabled = Boolean(fallbackEnabled);
|
||||
try {
|
||||
const managerEnabled = dataManager?.isEnabled?.(layerId);
|
||||
if (typeof managerEnabled === 'boolean') enabled = managerEnabled;
|
||||
} catch {
|
||||
// Retain the caller's observed fallback when the lightweight adapter fails.
|
||||
}
|
||||
return {
|
||||
enabled,
|
||||
lifecycleState: enabled ? 'enabled' : 'disabled',
|
||||
lifecycleUncertain: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** Create application actions over the supplied scene and services. */
|
||||
export function createGevActionRunner({ viewer, styleManager, dataManager, sceneDirector = null, annotations = null, placeSearch = unavailablePlaceSearch }) {
|
||||
installViewTargetPrewarm(viewer);
|
||||
initCameraVerbs(viewer, getViewTargetCartesian);
|
||||
|
||||
+6
-2732
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,37 @@
|
||||
/** Read layer lifecycle through the supplied manager. */
|
||||
export function readLayerLifecycleSummary(
|
||||
dataManager,
|
||||
layerId,
|
||||
{ fallbackEnabled = false } = {},
|
||||
) {
|
||||
let lifecycle = null;
|
||||
try {
|
||||
lifecycle = dataManager?.getLayerLifecycleState?.(layerId) || null;
|
||||
} catch {
|
||||
lifecycle = null;
|
||||
}
|
||||
if (lifecycle) {
|
||||
const enabled = Boolean(lifecycle.enabled);
|
||||
return {
|
||||
enabled,
|
||||
lifecycleState:
|
||||
lifecycle.lifecycleState || (enabled ? 'enabled' : 'disabled'),
|
||||
lifecycleUncertain: Boolean(
|
||||
lifecycle.uncertain ?? lifecycle.lifecycleUncertain,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
let enabled = Boolean(fallbackEnabled);
|
||||
try {
|
||||
const managerEnabled = dataManager?.isEnabled?.(layerId);
|
||||
if (typeof managerEnabled === 'boolean') enabled = managerEnabled;
|
||||
} catch {
|
||||
// Retain the caller's observed fallback when the lightweight adapter fails.
|
||||
}
|
||||
return {
|
||||
enabled,
|
||||
lifecycleState: enabled ? 'enabled' : 'disabled',
|
||||
lifecycleUncertain: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { DEFAULT_VOICE_TIER, resolveVoiceModel } from './voiceCost.js';
|
||||
|
||||
/** Realtime-compatible token and SDP requests, independent of microphone/UI ownership. */
|
||||
export function createRealtimeBackend({
|
||||
tokenEndpoint = '/api/realtime/token',
|
||||
callsEndpoint = 'https://api.openai.com/v1/realtime/calls',
|
||||
tokenTransport = (...args) => fetch(...args),
|
||||
connectionTransport = (...args) => fetch(...args),
|
||||
timeoutMs = 30_000,
|
||||
signal: lifetime,
|
||||
} = {}) {
|
||||
const scoped = (signal) =>
|
||||
AbortSignal.any(
|
||||
[lifetime, signal, AbortSignal.timeout(timeoutMs)].filter(Boolean),
|
||||
);
|
||||
return Object.freeze({
|
||||
protocol: 'openai-realtime',
|
||||
async requestToken({ tier = DEFAULT_VOICE_TIER, signal } = {}) {
|
||||
signal = scoped(signal);
|
||||
signal.throwIfAborted();
|
||||
const separator = tokenEndpoint.includes('?') ? '&' : '?';
|
||||
const url = `${tokenEndpoint}${separator}tier=${encodeURIComponent(resolveVoiceModel(tier).tier)}`;
|
||||
const response = await tokenTransport(url, {
|
||||
signal,
|
||||
cache: 'no-store',
|
||||
redirect: 'error',
|
||||
});
|
||||
signal.throwIfAborted();
|
||||
const data = await response.json().catch(() => null);
|
||||
signal.throwIfAborted();
|
||||
if (!response.ok) {
|
||||
const reason =
|
||||
typeof data?.error === 'string' ? data.error : data?.error?.message;
|
||||
throw new Error(
|
||||
reason || `Realtime token failed: HTTP ${response.status}`,
|
||||
);
|
||||
}
|
||||
const token =
|
||||
data?.value || data?.client_secret?.value || data?.client_secret;
|
||||
if (typeof token !== 'string' || !token)
|
||||
throw new Error(
|
||||
'Realtime token response did not include a client secret',
|
||||
);
|
||||
const expiresAt = data?.expires_at ?? data?.client_secret?.expires_at;
|
||||
if (
|
||||
expiresAt != null &&
|
||||
(!Number.isFinite(expiresAt) || expiresAt * 1000 <= Date.now())
|
||||
)
|
||||
throw new Error(
|
||||
'Realtime client secret has expired; reconnect to request a new one',
|
||||
);
|
||||
return {
|
||||
token,
|
||||
model:
|
||||
response.headers?.get?.('X-GEV-Voice-Model') ||
|
||||
data?.session?.model ||
|
||||
null,
|
||||
tier: response.headers?.get?.('X-GEV-Voice-Tier') || null,
|
||||
expiresAt,
|
||||
};
|
||||
},
|
||||
async negotiate({ offerSdp, credential, signal }) {
|
||||
signal = scoped(signal);
|
||||
signal.throwIfAborted();
|
||||
if (
|
||||
credential.expiresAt != null &&
|
||||
credential.expiresAt * 1000 <= Date.now()
|
||||
)
|
||||
throw new Error(
|
||||
'Realtime client secret has expired; reconnect to request a new one',
|
||||
);
|
||||
const response = await connectionTransport(callsEndpoint, {
|
||||
method: 'POST',
|
||||
body: offerSdp,
|
||||
signal,
|
||||
redirect: 'error',
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
Authorization: `Bearer ${credential.token}`,
|
||||
'Content-Type': 'application/sdp',
|
||||
},
|
||||
});
|
||||
signal.throwIfAborted();
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel?.().catch(() => {});
|
||||
throw new Error(`Realtime SDP failed: HTTP ${response.status}`);
|
||||
}
|
||||
const answer = await response.text();
|
||||
signal.throwIfAborted();
|
||||
return answer;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRealtimeBackend } from './realtimeBackend.js';
|
||||
import { GevRealtimeController } from './realtimeController.js';
|
||||
|
||||
const tokenReply = () =>
|
||||
Response.json({
|
||||
value: 'ephemeral-test',
|
||||
session: { model: 'compatible-model' },
|
||||
expires_at: Math.floor(Date.now() / 1000) + 60,
|
||||
});
|
||||
|
||||
test('separate configured token and SDP transports preserve model metadata and credential scope', async () => {
|
||||
const calls = [];
|
||||
const backend = createRealtimeBackend({
|
||||
tokenEndpoint: '/api/voice/token',
|
||||
callsEndpoint: 'https://voice.example/calls',
|
||||
tokenTransport: async (input, init) => {
|
||||
calls.push(input);
|
||||
assert.equal(init.redirect, 'error');
|
||||
assert.equal(init.headers, undefined);
|
||||
return tokenReply();
|
||||
},
|
||||
connectionTransport: async (input, init) => {
|
||||
calls.push(input);
|
||||
assert.equal(init.redirect, 'error');
|
||||
assert.equal(init.body, 'offer');
|
||||
assert.equal(init.headers.Authorization, 'Bearer ephemeral-test');
|
||||
return new Response('answer');
|
||||
},
|
||||
});
|
||||
const credential = await backend.requestToken({ tier: 'mini' });
|
||||
assert.equal(credential.model, 'compatible-model');
|
||||
assert.equal(
|
||||
await backend.negotiate({ credential, offerSdp: 'offer' }),
|
||||
'answer',
|
||||
);
|
||||
assert.deepEqual(calls, [
|
||||
'/api/voice/token?tier=mini',
|
||||
'https://voice.example/calls',
|
||||
]);
|
||||
});
|
||||
|
||||
test('denied tokens never negotiate or retry; reconnect requests a fresh token', async () => {
|
||||
let requests = 0;
|
||||
const backend = createRealtimeBackend({
|
||||
tokenTransport: async () => {
|
||||
requests++;
|
||||
return requests === 1
|
||||
? Response.json({ error: 'Unavailable' }, { status: 401 })
|
||||
: tokenReply();
|
||||
},
|
||||
connectionTransport: () => assert.fail('unexpected connection'),
|
||||
});
|
||||
await assert.rejects(backend.requestToken(), /Unavailable/);
|
||||
assert.equal(requests, 1);
|
||||
assert.equal((await backend.requestToken()).token, 'ephemeral-test');
|
||||
assert.equal(requests, 2);
|
||||
});
|
||||
|
||||
test('expired and malformed client secrets are rejected before SDP exchange', async () => {
|
||||
for (const data of [
|
||||
{ value: 'test', expires_at: 1 },
|
||||
{ value: {} },
|
||||
{ value: 'test', expires_at: 'later' },
|
||||
]) {
|
||||
const backend = createRealtimeBackend({
|
||||
tokenTransport: async () => Response.json(data),
|
||||
});
|
||||
await assert.rejects(backend.requestToken(), /expired|client secret/);
|
||||
}
|
||||
const backend = createRealtimeBackend({
|
||||
connectionTransport: () => assert.fail('expired token transmitted'),
|
||||
});
|
||||
await assert.rejects(
|
||||
backend.negotiate({
|
||||
credential: { token: 'test', expiresAt: 1 },
|
||||
offerSdp: 'offer',
|
||||
}),
|
||||
/expired/,
|
||||
);
|
||||
});
|
||||
|
||||
test('cancellation rejects late token and SDP bodies without promoting a stopped connection', async () => {
|
||||
for (const phase of ['token', 'sdp']) {
|
||||
const lifetime = new AbortController();
|
||||
let release;
|
||||
const pending = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const backend = createRealtimeBackend({
|
||||
signal: lifetime.signal,
|
||||
tokenTransport: async () => ({ ok: true, json: () => pending }),
|
||||
connectionTransport: async () => ({ ok: true, text: () => pending }),
|
||||
});
|
||||
const result =
|
||||
phase === 'token'
|
||||
? backend.requestToken()
|
||||
: backend.negotiate({
|
||||
credential: { token: 'test' },
|
||||
offerSdp: 'offer',
|
||||
signal: lifetime.signal,
|
||||
});
|
||||
await Promise.resolve();
|
||||
lifetime.abort();
|
||||
release(phase === 'token' ? { value: 'late' } : 'late answer');
|
||||
await assert.rejects(result, { name: 'AbortError' });
|
||||
}
|
||||
});
|
||||
|
||||
test('controller lifetime stops pending transport and releases resources through normal teardown', () => {
|
||||
const lifetime = new AbortController();
|
||||
let stopped = 0;
|
||||
const controller = new GevRealtimeController({
|
||||
runner: async () => ({}),
|
||||
signal: lifetime.signal,
|
||||
debugSink: null,
|
||||
ui: {
|
||||
root: { dataset: {}, querySelectorAll: () => [], remove() {} },
|
||||
status: {},
|
||||
detail: {},
|
||||
},
|
||||
});
|
||||
controller.connectionAbort = new AbortController();
|
||||
const connection = controller.connectionAbort.signal;
|
||||
controller.stream = { getTracks: () => [{ stop: () => stopped++ }] };
|
||||
lifetime.abort();
|
||||
assert.equal(connection.aborted, true);
|
||||
assert.equal(stopped, 1);
|
||||
assert.equal(controller.stream, null);
|
||||
assert.equal(controller.isActive(), false);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user