Files
gods-eye-view/src/voice/realtimeBackend.js
T
Sameh Khamis fb1596d2f1 Merge main into PR #621 (ChatGPT OAuth for Realtime voice)
Resolve conflicts with the voice-core captions, panel resize and MCP
panel key changes by keeping both sides.
2026-10-08 11:00:54 -07:00

104 lines
3.4 KiB
JavaScript

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,
authMode = 'api-key',
signal,
} = {}) {
signal = scoped(signal);
signal.throwIfAborted();
const separator = tokenEndpoint.includes('?') ? '&' : '?';
const query = new URLSearchParams({
tier: resolveVoiceModel(tier).tier,
});
if (authMode === 'oauth') query.set('auth', 'oauth');
const url = `${tokenEndpoint}${separator}${query}`;
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,
transcribeModel:
response.headers?.get?.('X-GEV-Voice-Transcribe-Model') || 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;
},
});
}