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}`,