/** * Voice settings from the environment. Model ids live here and nowhere else * (spec 7.1): a change of model is a change of this list and of VOICE_MODEL. */ export const VOICE_MODELS = [ 'claude-sonnet-5-5', 'claude-haiku-5-5', 'claude-opus-5-5', ] as const; export type VoiceModel = (typeof VOICE_MODELS)[number]; export const DEFAULT_VOICE_MODEL: VoiceModel = 'claude-sonnet-5-5'; /** * Models with a server-side fallback when their safety check declines * (`fallbacks: "default"`). Haiku has none: a declined request stays * declined there. */ export const VOICE_SERVER_FALLBACK_MODELS: readonly string[] = [ 'claude-sonnet-5-5', 'claude-opus-5-5', ]; /** VOICE_SERVER_FALLBACK: "default" uses the fallback above, "off" never. */ export const SERVER_FALLBACK_MODES = ['off', 'default'] as const; export type ServerFallbackMode = (typeof SERVER_FALLBACK_MODES)[number]; export const WHISPER_MODELS = ['small', 'medium'] as const; export type WhisperModel = (typeof WHISPER_MODELS)[number]; const DEFAULT_DAILY_EXTRACTIONS = 60; const MIN_DAILY_EXTRACTIONS = 1; const MAX_DAILY_EXTRACTIONS = 10000; export interface VoiceConfig { model: VoiceModel; /** Operation-wide limit of Claude calls per UTC day. */ dailyExtractions: number; /** Switch that shows or hides audio recording in the app (ConfigMap). */ recordingEnabled: boolean; whisperModel: WhisperModel; /** Whether a declined request may be re-run server-side on another model. */ serverFallback: ServerFallbackMode; } function trimmed(value: string | undefined): string | null { const text = value?.trim(); return text ? text : null; } function oneOf( name: string, raw: string | undefined, allowed: readonly T[], fallback: T, ): T { const value = trimmed(raw); if (value === null) { return fallback; } const match = allowed.find((each) => each === value); if (match === undefined) { throw new Error(`${name} must be one of ${allowed.join(', ')}`); } return match; } function dailyExtractions(raw: string | undefined): number { const value = trimmed(raw); if (value === null) { return DEFAULT_DAILY_EXTRACTIONS; } const number = /^\d+$/.test(value) ? Number(value) : NaN; if ( !Number.isInteger(number) || number < MIN_DAILY_EXTRACTIONS || number > MAX_DAILY_EXTRACTIONS ) { throw new Error( `VOICE_DAILY_EXTRACTIONS must be a whole number from ${MIN_DAILY_EXTRACTIONS} to ${MAX_DAILY_EXTRACTIONS}`, ); } return number; } function recordingEnabled(raw: string | undefined): boolean { const value = trimmed(raw)?.toLowerCase() ?? null; if (value === null || value === 'false') { return false; } if (value === 'true') { return true; } throw new Error('VOICE_RECORDING_ENABLED must be "true" or "false"'); } /** Invalid values fail with the variable name (never a secret): startup refuses them. */ export function voiceConfig(env: NodeJS.ProcessEnv = process.env): VoiceConfig { return { model: oneOf( 'VOICE_MODEL', env.VOICE_MODEL, VOICE_MODELS, DEFAULT_VOICE_MODEL, ), dailyExtractions: dailyExtractions(env.VOICE_DAILY_EXTRACTIONS), recordingEnabled: recordingEnabled(env.VOICE_RECORDING_ENABLED), whisperModel: oneOf( 'WHISPER_MODEL', env.WHISPER_MODEL, WHISPER_MODELS, 'small', ), serverFallback: oneOf( 'VOICE_SERVER_FALLBACK', env.VOICE_SERVER_FALLBACK, SERVER_FALLBACK_MODES, 'default', ), }; }