diff --git a/server/providers/openai/realtime.js b/server/providers/openai/realtime.js index 73ac8d2cf..1242051f5 100644 --- a/server/providers/openai/realtime.js +++ b/server/providers/openai/realtime.js @@ -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; } } diff --git a/src/ui/views/panels/VoiceControl.vue b/src/ui/views/panels/VoiceControl.vue index 8c1144e52..172077425 100644 --- a/src/ui/views/panels/VoiceControl.vue +++ b/src/ui/views/panels/VoiceControl.vue @@ -166,7 +166,7 @@ const frameStyle = (panel) => ({
{{ view.error.text }}
-
{{ t('Check microphone permission and network access, then try again.') }}
+
{{ t('Check microphone permission and network access, then try again.') }}
diff --git a/src/voice/control.js b/src/voice/control.js index 61da65bfc..58cf08f2f 100644 --- a/src/voice/control.js +++ b/src/voice/control.js @@ -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: { diff --git a/src/voice/realtimeBackend.js b/src/voice/realtimeBackend.js index ca38432ee..a9c10f2af 100644 --- a/src/voice/realtimeBackend.js +++ b/src/voice/realtimeBackend.js @@ -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 = diff --git a/src/voice/realtimeBackend.test.mjs b/src/voice/realtimeBackend.test.mjs index ac96e2374..b60bf8406 100644 --- a/src/voice/realtimeBackend.test.mjs +++ b/src/voice/realtimeBackend.test.mjs @@ -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', + }); +}); diff --git a/src/voice/realtimeController.js b/src/voice/realtimeController.js index f545ae787..cb86d419e 100644 --- a/src/voice/realtimeController.js +++ b/src/voice/realtimeController.js @@ -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'); diff --git a/src/voice/realtimeDiagnostics.js b/src/voice/realtimeDiagnostics.js index 55e078280..dcdacf08e 100644 --- a/src/voice/realtimeDiagnostics.js +++ b/src/voice/realtimeDiagnostics.js @@ -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}`,