From ac37d2ad397e57cfbf92376300fff326f3f420e8 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:16:33 +0000 Subject: [PATCH 1/5] feat(voice): configurable ElevenLabs max conversation duration Auto-created ConvAI agents never set conversation.max_duration_seconds, so ElevenLabs applied its 600s default and cut voice conversations off. Add ELEVENLABS_MAX_DURATION_SECONDS (default 1800, validated 60-7200). The cap is applied when the hub creates an agent and reconciled onto existing hub-managed agents (found by name) once per process. Operator-supplied agents (ELEVENLABS_AGENT_ID / voice map / customAgentId) keep their own cap. Invalid values fail the token request with a clear message instead of an API 422. Co-Authored-By: Claude Sonnet 5.5 --- docs/guide/installation.md | 1 + docs/guide/voice-assistant.md | 8 +++ hub/src/web/routes/voice.test.ts | 109 ++++++++++++++++++++++++++++++ hub/src/web/routes/voice.ts | 41 +++++++++-- shared/src/voice.backends.test.ts | 27 ++++++++ shared/src/voice.ts | 41 ++++++++++- 6 files changed, 220 insertions(+), 7 deletions(-) diff --git a/docs/guide/installation.md b/docs/guide/installation.md index b90e27d60b..70c36e5fec 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -190,6 +190,7 @@ On first run, HAPI: | `HAPI_EXPERIMENTAL` | - | - | CLI: enable experimental features (`true`/`1`/`yes`) | | `ELEVENLABS_API_KEY` | - | Settings / env | ElevenLabs API key for voice + dictation | | `ELEVENLABS_AGENT_ID` | Auto-created | - | Custom ElevenLabs agent ID | +| `ELEVENLABS_MAX_DURATION_SECONDS` | `1800` | - | Max length of one ElevenLabs voice conversation (60-7200) | | `GEMINI_API_KEY` / `GOOGLE_API_KEY` | - | Settings / env | Gemini Live voice assistant | | `DASHSCOPE_API_KEY` / `QWEN_API_KEY` | - | Settings / env | Qwen Realtime voice assistant | | `VOICE_BACKEND` | Auto-detected | - | Default assistant backend: `elevenlabs`, `gemini-live`, or `qwen-realtime` | diff --git a/docs/guide/voice-assistant.md b/docs/guide/voice-assistant.md index 64a8a54da1..034259cfd1 100644 --- a/docs/guide/voice-assistant.md +++ b/docs/guide/voice-assistant.md @@ -74,6 +74,14 @@ hapi hub --relay The hub automatically creates a "Hapi Voice Assistant" agent in your ElevenLabs account on first use. When you pick a non-default voice, the hub creates a dedicated per-voice agent named `Hapi Voice Assistant [voice:]` so the selection always takes effect. +Each conversation is capped at 1800 seconds (ElevenLabs' own default of 600s cuts conversations off). The cap also bounds the cost of a conversation left open, since ConvAI bills per minute. Change it with: + +```bash +export ELEVENLABS_MAX_DURATION_SECONDS=3600 # 60-7200 +``` + +The cap is set when an agent is created and re-applied to existing auto-created agents the first time the hub serves a token for them after a start, so a hub restart picks up a new value. Agents you supply yourself (below) keep their own cap. + To use your own ElevenLabs agent instead of the auto-created one: ```bash diff --git a/hub/src/web/routes/voice.test.ts b/hub/src/web/routes/voice.test.ts index 729203133c..119bd34788 100644 --- a/hub/src/web/routes/voice.test.ts +++ b/hub/src/web/routes/voice.test.ts @@ -549,6 +549,115 @@ describe('POST /api/voice/token', () => { }) }) +describe('POST /api/voice/token max conversation duration', () => { + type Call = { url: string; method?: string; body: any } + + async function runToken(opts: { + env?: string + existingAgent?: boolean + envAgentId?: string + }) { + const app = createApp() + const headers = { ...(await authHeaders()), 'content-type': 'application/json' } + const suffix = Math.random().toString(36).slice(2, 10) + const voiceId = `dur-voice-${suffix}` + const prev = { + key: process.env.ELEVENLABS_API_KEY, + agent: process.env.ELEVENLABS_AGENT_ID, + max: process.env.ELEVENLABS_MAX_DURATION_SECONDS + } + process.env.ELEVENLABS_API_KEY = `dur-key-${suffix}` + if (opts.envAgentId) process.env.ELEVENLABS_AGENT_ID = opts.envAgentId + else delete process.env.ELEVENLABS_AGENT_ID + if (opts.env === undefined) delete process.env.ELEVENLABS_MAX_DURATION_SECONDS + else process.env.ELEVENLABS_MAX_DURATION_SECONDS = opts.env + + const calls: Call[] = [] + const originalFetch = global.fetch + // @ts-expect-error test override + global.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + const body = init?.body ? JSON.parse(String(init.body)) : null + calls.push({ url, method: init?.method, body }) + if (url.endsWith('/convai/agents') && init?.method === 'GET') { + return new Response(JSON.stringify({ + agents: opts.existingAgent + ? [{ agent_id: `agent_${suffix}`, name: `Hapi Voice Assistant [voice:${voiceId}]` }] + : [] + }), { status: 200 }) + } + if (url.endsWith('/convai/agents/create')) { + return new Response(JSON.stringify({ agent_id: `agent_${suffix}` }), { status: 200 }) + } + if (init?.method === 'PATCH') { + return new Response(JSON.stringify({}), { status: 200 }) + } + if (url.includes('/convai/conversation/token?agent_id=')) { + return new Response(JSON.stringify({ token: 'tok' }), { status: 200 }) + } + return new Response('not found', { status: 404 }) + }) as typeof fetch + + try { + const res = await app.request('/api/voice/token', { + method: 'POST', + headers, + body: JSON.stringify(opts.envAgentId ? {} : { voiceId }) + }) + return { res, calls } + } finally { + global.fetch = originalFetch + for (const [name, value] of [ + ['ELEVENLABS_API_KEY', prev.key], + ['ELEVENLABS_AGENT_ID', prev.agent], + ['ELEVENLABS_MAX_DURATION_SECONDS', prev.max] + ] as const) { + if (value === undefined) delete process.env[name] + else process.env[name] = value + } + } + } + + it('creates new agents with the default 1800s cap (not ElevenLabs\' 600s)', async () => { + const { res, calls } = await runToken({}) + expect(res.status).toBe(200) + const create = calls.find(c => c.url.endsWith('/convai/agents/create')) + expect(create?.body.conversation_config.conversation.max_duration_seconds).toBe(1800) + }) + + it('creates new agents with the configured cap', async () => { + const { calls } = await runToken({ env: '3600' }) + const create = calls.find(c => c.url.endsWith('/convai/agents/create')) + expect(create?.body.conversation_config.conversation.max_duration_seconds).toBe(3600) + }) + + it('reconciles the cap onto existing hub-managed agents', async () => { + const { res, calls } = await runToken({ env: '3600', existingAgent: true }) + expect(res.status).toBe(200) + expect(calls.some(c => c.url.endsWith('/convai/agents/create'))).toBe(false) + const patch = calls.find(c => c.method === 'PATCH') + expect(patch?.body.conversation_config.conversation.max_duration_seconds).toBe(3600) + }) + + it('does not touch the cap on operator-supplied agents', async () => { + const { calls } = await runToken({ env: '3600', envAgentId: 'agent_operator_owned' }) + const patch = calls.find(c => c.method === 'PATCH') + expect(patch).toBeTruthy() + expect(patch?.body.conversation_config).toBeUndefined() + }) + + for (const bad of ['59', '7201', 'abc', '1800.5']) { + it(`rejects out-of-range/invalid value "${bad}" with a clear message and creates nothing`, async () => { + const { res, calls } = await runToken({ env: bad }) + expect(res.status).toBe(500) + const json = await res.json() as { allowed: boolean; error: string } + expect(json.allowed).toBe(false) + expect(json.error).toContain('ELEVENLABS_MAX_DURATION_SECONDS must be an integer between 60 and 7200') + expect(calls.some(c => c.url.endsWith('/convai/agents/create'))).toBe(false) + }) + } +}) + describe('GET /api/voice/backend', () => { const originalEnv = { VOICE_BACKEND: process.env.VOICE_BACKEND, diff --git a/hub/src/web/routes/voice.ts b/hub/src/web/routes/voice.ts index da298594b1..faf1522fbe 100644 --- a/hub/src/web/routes/voice.ts +++ b/hub/src/web/routes/voice.ts @@ -12,6 +12,7 @@ import { OPENAI_TRANSCRIPTION_MODEL, VOICE_AGENT_NAME, buildVoiceAgentConfig, + resolveVoiceMaxDurationSeconds, listConfiguredTranscriptionProviders, listConfiguredVoiceBackends, resolveHubVoiceBackend @@ -254,6 +255,11 @@ const agentIdCache = new Map() // reconciled with the canonical buildVoiceAgentConfig() shape. Reset on process restart. const overridesEnsuredAgents = new Set() +/** Configured ConvAI conversation cap; throws if ELEVENLABS_MAX_DURATION_SECONDS is invalid. */ +function getMaxDurationSeconds(): number { + return resolveVoiceMaxDurationSeconds(process.env.ELEVENLABS_MAX_DURATION_SECONDS) +} + interface ElevenLabsAgent { agent_id: string name: string @@ -270,11 +276,20 @@ interface ElevenLabsAgent { * * See: https://elevenlabs.io/docs/agents-platform/customization/personalization/overrides */ -async function ensureAgentOverrides(apiKey: string, agentId: string): Promise { +async function ensureAgentOverrides( + apiKey: string, + agentId: string, + options: { reconcileMaxDuration?: boolean } = {} +): Promise { if (overridesEnsuredAgents.has(agentId)) return overridesEnsuredAgents.add(agentId) - const canonical = buildVoiceAgentConfig().platform_settings + // The conversation cap is only enforced on agents the hub itself manages + // (found/created by name); operator-supplied agents keep their own cap. + const canonicalConfig = buildVoiceAgentConfig( + options.reconcileMaxDuration ? { maxDurationSeconds: getMaxDurationSeconds() } : {} + ) + const canonical = canonicalConfig.platform_settings if (!canonical) return try { @@ -287,7 +302,12 @@ async function ensureAgentOverrides(apiKey: string, agentId: string): Promise { async function createNamedHapiAgent(apiKey: string, agentName: string, voiceId?: string): Promise { try { - const config = buildVoiceAgentConfig() + const config = buildVoiceAgentConfig({ maxDurationSeconds: getMaxDurationSeconds() }) config.name = agentName if (voiceId) { config.conversation_config.tts.voice_id = voiceId @@ -436,7 +456,7 @@ async function getOrCreateAgentIdForVoice(apiKey: string, voiceId?: string): Pro console.log('[Voice] Found existing agent:', agentId) // Existing agents may predate the platform_settings.overrides we declare // in buildVoiceAgentConfig() — reconcile so client overrides are accepted. - await ensureAgentOverrides(apiKey, agentId) + await ensureAgentOverrides(apiKey, agentId, { reconcileMaxDuration: true }) } else { // Create new agent console.log('[Voice] No existing agent found, creating new one...') @@ -633,6 +653,15 @@ export function createVoiceRoutes(options: { dataDir?: string } = {}): Hono { expect(resolveEffectiveVoiceBackend([], null, null)).toBeNull() }) }) + +describe('resolveVoiceMaxDurationSeconds', () => { + test('defaults to 1800s when unset or blank', () => { + expect(resolveVoiceMaxDurationSeconds(undefined)).toBe(1800) + expect(resolveVoiceMaxDurationSeconds(' ')).toBe(1800) + }) + + test('accepts the inclusive 60..7200 range', () => { + expect(resolveVoiceMaxDurationSeconds('60')).toBe(60) + expect(resolveVoiceMaxDurationSeconds(' 7200 ')).toBe(7200) + }) + + test('rejects out-of-range and non-integer values', () => { + for (const bad of ['59', '7201', '0', '-1', 'abc', '1800.5']) { + expect(() => resolveVoiceMaxDurationSeconds(bad)).toThrow(/between 60 and 7200/) + } + }) +}) + +describe('buildVoiceAgentConfig max duration', () => { + test('omits conversation cap unless requested, includes it when given', () => { + expect(buildVoiceAgentConfig().conversation_config.conversation).toBeUndefined() + expect(buildVoiceAgentConfig({ maxDurationSeconds: 900 }).conversation_config.conversation) + .toEqual({ max_duration_seconds: 900 }) + }) +}) diff --git a/shared/src/voice.ts b/shared/src/voice.ts index 9a81d0bb49..2509eb7380 100644 --- a/shared/src/voice.ts +++ b/shared/src/voice.ts @@ -17,6 +17,37 @@ export { VOICE_CHINESE_LANGUAGE_BLOCK } from './voicePromptLayers' export const ELEVENLABS_API_BASE = 'https://api.elevenlabs.io/v1' export const VOICE_AGENT_NAME = 'Hapi Voice Assistant' +/** ElevenLabs ConvAI accepts conversation caps of 60s..7200s. */ +export const VOICE_MAX_DURATION_MIN_SECONDS = 60 +export const VOICE_MAX_DURATION_MAX_SECONDS = 7200 +/** + * Default cap on a single ConvAI conversation. ConvAI bills per minute, so this + * also bounds the cost of a dead-but-open conversation; deliberately well below + * the 7200s ceiling. ElevenLabs' own default (600s) cuts real conversations off. + */ +export const VOICE_MAX_DURATION_DEFAULT_SECONDS = 1800 + +/** + * Resolve the configured conversation cap (env ELEVENLABS_MAX_DURATION_SECONDS). + * Unset/blank -> default. Throws a descriptive Error when not an integer in range. + */ +export function resolveVoiceMaxDurationSeconds(raw: string | undefined): number { + const text = raw?.trim() + if (!text) return VOICE_MAX_DURATION_DEFAULT_SECONDS + const value = Number(text) + if ( + !Number.isInteger(value) + || value < VOICE_MAX_DURATION_MIN_SECONDS + || value > VOICE_MAX_DURATION_MAX_SECONDS + ) { + throw new Error( + `ELEVENLABS_MAX_DURATION_SECONDS must be an integer between ` + + `${VOICE_MAX_DURATION_MIN_SECONDS} and ${VOICE_MAX_DURATION_MAX_SECONDS} (got "${text}")` + ) + } + return value +} + const DEFAULT_COMPOSED_LAYERS: VoicePromptLayerInput = { identity: '', character: '', @@ -161,6 +192,9 @@ export interface VoiceAgentConfig { turn_timeout: number silence_end_call_timeout: number } + conversation?: { + max_duration_seconds: number + } tts: { voice_id: string model_id: string @@ -194,7 +228,7 @@ export interface VoiceAgentConfig { * Build the agent configuration for Hapi Voice Assistant. * Used by both server-side auto-creation and client-side configuration. */ -export function buildVoiceAgentConfig(): VoiceAgentConfig { +export function buildVoiceAgentConfig(options: { maxDurationSeconds?: number } = {}): VoiceAgentConfig { return { name: VOICE_AGENT_NAME, conversation_config: { @@ -213,6 +247,11 @@ export function buildVoiceAgentConfig(): VoiceAgentConfig { turn_timeout: 30.0, silence_end_call_timeout: 600.0 }, + // Omitted unless provided so clients that PATCH an existing agent + // without knowing the hub setting do not clobber its cap. + ...(options.maxDurationSeconds !== undefined + ? { conversation: { max_duration_seconds: options.maxDurationSeconds } } + : {}), tts: { voice_id: 'cgSgspJ2msm6clMCkdW9', // Jessica model_id: 'eleven_flash_v2', From d23172d07c64be1e9533c6c8aa27cfbddf3a85b5 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:44:32 +0000 Subject: [PATCH 2/5] feat(voice): Settings UI for ElevenLabs max conversation duration Extend the hub-owner credentials panel (Settings -> Voice -> manage providers) with a numeric field for ELEVENLABS_MAX_DURATION_SECONDS, reusing the same env-wins-over-Settings overlay that already backs the ElevenLabs API key field. A Settings save takes effect on the very next token request in the same process; the env var still requires a restart. Co-Authored-By: Claude Sonnet 5.5 --- docs/guide/voice-assistant.md | 4 +- hub/src/config/providerCredentials.test.ts | 102 ++++++++++++++++++ hub/src/config/providerCredentials.ts | 93 +++++++++++++++- hub/src/config/settings.ts | 5 + hub/src/web/routes/voice.test.ts | 98 ++++++++++++++++- hub/src/web/routes/voice.ts | 8 +- web/src/api/client.ts | 8 ++ .../settings/TranscriptionProviderOnboard.tsx | 94 ++++++++++++++++ web/src/lib/locales/en.ts | 6 ++ web/src/lib/locales/ru.ts | 6 ++ web/src/lib/locales/zh-CN.ts | 6 ++ 11 files changed, 420 insertions(+), 10 deletions(-) diff --git a/docs/guide/voice-assistant.md b/docs/guide/voice-assistant.md index 034259cfd1..f0f1ba44c3 100644 --- a/docs/guide/voice-assistant.md +++ b/docs/guide/voice-assistant.md @@ -74,13 +74,13 @@ hapi hub --relay The hub automatically creates a "Hapi Voice Assistant" agent in your ElevenLabs account on first use. When you pick a non-default voice, the hub creates a dedicated per-voice agent named `Hapi Voice Assistant [voice:]` so the selection always takes effect. -Each conversation is capped at 1800 seconds (ElevenLabs' own default of 600s cuts conversations off). The cap also bounds the cost of a conversation left open, since ConvAI bills per minute. Change it with: +Each conversation is capped at 1800 seconds (ElevenLabs' own default of 600s cuts conversations off). The cap also bounds the cost of a conversation left open, since ConvAI bills per minute. Change it in Settings → Voice → manage providers (hub owner only), under the ElevenLabs entry, or with: ```bash export ELEVENLABS_MAX_DURATION_SECONDS=3600 # 60-7200 ``` -The cap is set when an agent is created and re-applied to existing auto-created agents the first time the hub serves a token for them after a start, so a hub restart picks up a new value. Agents you supply yourself (below) keep their own cap. +The env var wins over the Settings value and disables the Settings field. Either way, the cap is set when an agent is created and re-applied to existing hub-managed agents the next time a token is issued for them — a Settings change takes effect immediately; an env var change needs a hub restart. Agents you supply yourself (below) keep their own cap. To use your own ElevenLabs agent instead of the auto-created one: diff --git a/hub/src/config/providerCredentials.test.ts b/hub/src/config/providerCredentials.test.ts index b90df55c48..50c6d84489 100644 --- a/hub/src/config/providerCredentials.test.ts +++ b/hub/src/config/providerCredentials.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { applyProviderCredentialsFromSettings, + getConfiguredElevenLabsMaxDurationSeconds, getProviderEnvironment, getTranscriptionCredentialStatus, maskSecret, @@ -23,6 +24,7 @@ const MANAGED_KEYS = [ 'GOOGLE_API_KEY', 'DASHSCOPE_API_KEY', 'QWEN_API_KEY', + 'ELEVENLABS_MAX_DURATION_SECONDS', ] as const function makeTempDir(): string { @@ -415,4 +417,104 @@ describe('providerCredentials', () => { expect(status.openaiCompatible.baseUrl).toBeNull() expect(status.openaiCompatible.model).toBeNull() }) + + describe('elevenLabsMaxDurationSeconds', () => { + it('defaults to 1800s with source "none" when nothing is configured', async () => { + dir = makeTempDir() + writeFileSync(join(dir, 'settings.json'), JSON.stringify({})) + await applyProviderCredentialsFromSettings(dir) + + expect(getConfiguredElevenLabsMaxDurationSeconds()).toBe(1800) + const status = await getTranscriptionCredentialStatus(dir) + expect(status.elevenLabsMaxDurationSeconds).toEqual({ + value: 1800, + source: 'none', + editable: true, + }) + }) + + it('persists a configured value through Settings and takes effect immediately (no restart)', async () => { + dir = makeTempDir() + writeFileSync(join(dir, 'settings.json'), JSON.stringify({})) + await applyProviderCredentialsFromSettings(dir) + + const status = await updateTranscriptionCredentials(dir, { elevenLabsMaxDurationSeconds: 3600 }) + expect(status.elevenLabsMaxDurationSeconds).toEqual({ + value: 3600, + source: 'settings', + editable: true, + }) + // The in-memory overlay (what the token route reads) updates on the same + // process without needing applyProviderCredentialsFromSettings to re-run. + expect(getConfiguredElevenLabsMaxDurationSeconds()).toBe(3600) + + const saved = JSON.parse(readFileSync(join(dir, 'settings.json'), 'utf8')) as { + elevenLabsMaxDurationSeconds?: number + } + expect(saved.elevenLabsMaxDurationSeconds).toBe(3600) + }) + + it('resets to the default when set to null', async () => { + dir = makeTempDir() + writeFileSync(join(dir, 'settings.json'), JSON.stringify({ elevenLabsMaxDurationSeconds: 3600 })) + await applyProviderCredentialsFromSettings(dir) + expect(getConfiguredElevenLabsMaxDurationSeconds()).toBe(3600) + + const status = await updateTranscriptionCredentials(dir, { elevenLabsMaxDurationSeconds: null }) + expect(status.elevenLabsMaxDurationSeconds).toEqual({ + value: 1800, + source: 'none', + editable: true, + }) + expect(getConfiguredElevenLabsMaxDurationSeconds()).toBe(1800) + }) + + it('rejects out-of-range values with a clear message and does not persist them', async () => { + dir = makeTempDir() + writeFileSync(join(dir, 'settings.json'), JSON.stringify({})) + await applyProviderCredentialsFromSettings(dir) + + await expect( + updateTranscriptionCredentials(dir, { elevenLabsMaxDurationSeconds: 59 }) + ).rejects.toThrow(/must be an integer between 60 and 7200/) + await expect( + updateTranscriptionCredentials(dir, { elevenLabsMaxDurationSeconds: 7201 }) + ).rejects.toThrow(/must be an integer between 60 and 7200/) + + expect(getConfiguredElevenLabsMaxDurationSeconds()).toBe(1800) + const saved = JSON.parse(readFileSync(join(dir, 'settings.json'), 'utf8')) as { + elevenLabsMaxDurationSeconds?: number + } + expect(saved.elevenLabsMaxDurationSeconds).toBeUndefined() + }) + + it('refuses to change a value locked by the environment', async () => { + dir = makeTempDir() + writeFileSync(join(dir, 'settings.json'), JSON.stringify({})) + process.env.ELEVENLABS_MAX_DURATION_SECONDS = '3600' + await applyProviderCredentialsFromSettings(dir) + + expect(getConfiguredElevenLabsMaxDurationSeconds()).toBe(3600) + await expect( + updateTranscriptionCredentials(dir, { elevenLabsMaxDurationSeconds: 1800 }) + ).rejects.toThrow(/environment variable/) + + const status = await getTranscriptionCredentialStatus(dir) + expect(status.elevenLabsMaxDurationSeconds).toEqual({ + value: 3600, + source: 'env', + editable: false, + }) + }) + + it('ignores a stale out-of-range value already on disk rather than crashing', async () => { + dir = makeTempDir() + writeFileSync(join(dir, 'settings.json'), JSON.stringify({ elevenLabsMaxDurationSeconds: 9999 })) + await applyProviderCredentialsFromSettings(dir) + + expect(getConfiguredElevenLabsMaxDurationSeconds()).toBe(1800) + const status = await getTranscriptionCredentialStatus(dir) + expect(status.elevenLabsMaxDurationSeconds.source).toBe('none') + }) + }) }) diff --git a/hub/src/config/providerCredentials.ts b/hub/src/config/providerCredentials.ts index 192f58f61f..3818eb6df2 100644 --- a/hub/src/config/providerCredentials.ts +++ b/hub/src/config/providerCredentials.ts @@ -8,6 +8,7 @@ */ import { getSettingsFile, readSettings, updateSettings, type Settings } from './settings' +import { VOICE_MAX_DURATION_DEFAULT_SECONDS, resolveVoiceMaxDurationSeconds } from '@hapi/protocol/voice' export const PROVIDER_CREDENTIAL_ENV_KEYS = [ 'OPENAI_API_KEY', @@ -48,6 +49,12 @@ export interface OpenAICompatibleCredentialStatus { apiKey: MaskedCredentialStatus } +export interface MaxDurationStatus { + value: number + source: ProviderCredentialSource + editable: boolean +} + export interface TranscriptionCredentialStatus { openai: MaskedCredentialStatus elevenlabs: MaskedCredentialStatus @@ -59,6 +66,7 @@ export interface TranscriptionCredentialStatus { geminiLive: MaskedCredentialStatus qwenRealtime: MaskedCredentialStatus } + elevenLabsMaxDurationSeconds: MaxDurationStatus } export interface TranscriptionCredentialsUpdate { @@ -73,15 +81,58 @@ export interface TranscriptionCredentialsUpdate { } geminiLive?: string | null qwenRealtime?: string | null + /** null resets to VOICE_MAX_DURATION_DEFAULT_SECONDS */ + elevenLabsMaxDurationSeconds?: number | null } export type ProviderCredentialsMap = Partial> let envLockedKeys = new Set() +let settingsBackedMaxDurationSeconds: number | undefined export function resetProviderCredentialEnvLocksForTests(): void { envLockedKeys = new Set() settingsBackedCredentials = {} + settingsBackedMaxDurationSeconds = undefined +} + +function isMaxDurationEnvLocked(): boolean { + return Boolean(process.env.ELEVENLABS_MAX_DURATION_SECONDS?.trim()) +} + +/** Coerce a stored value to a valid cap, dropping anything that's since fallen out of range. */ +function normalizeStoredMaxDuration(value: unknown): number | undefined { + if (typeof value !== 'number') return undefined + try { + return resolveVoiceMaxDurationSeconds(String(value)) + } catch { + return undefined + } +} + +/** + * Effective ElevenLabs ConvAI conversation cap (seconds). Env wins when set; + * otherwise the Settings-backed override, else the documented default. + */ +export function getConfiguredElevenLabsMaxDurationSeconds(): number { + if (isMaxDurationEnvLocked()) { + return resolveVoiceMaxDurationSeconds(process.env.ELEVENLABS_MAX_DURATION_SECONDS) + } + return settingsBackedMaxDurationSeconds ?? VOICE_MAX_DURATION_DEFAULT_SECONDS +} + +function buildMaxDurationStatus(maxDurationSeconds: number | undefined): MaxDurationStatus { + if (isMaxDurationEnvLocked()) { + return { + value: resolveVoiceMaxDurationSeconds(process.env.ELEVENLABS_MAX_DURATION_SECONDS), + source: 'env', + editable: false, + } + } + if (maxDurationSeconds !== undefined) { + return { value: maxDurationSeconds, source: 'settings', editable: true } + } + return { value: VOICE_MAX_DURATION_DEFAULT_SECONDS, source: 'none', editable: true } } export function maskSecret(value: string): string { @@ -220,12 +271,17 @@ export async function applyProviderCredentialsFromSettings(dataDir: string): Pro const settings = await readSettings(getSettingsFile(dataDir)) if (settings === null) { setSettingsBackedCredentials({}) + settingsBackedMaxDurationSeconds = undefined return } setSettingsBackedCredentials(readProviderCredentials(settings)) + settingsBackedMaxDurationSeconds = normalizeStoredMaxDuration(settings.elevenLabsMaxDurationSeconds) } -function buildStatus(stored: ProviderCredentialsMap): TranscriptionCredentialStatus { +function buildStatus( + stored: ProviderCredentialsMap, + maxDurationSeconds: number | undefined +): TranscriptionCredentialStatus { const openai = statusForKey('OPENAI_API_KEY', stored) const elevenlabs = statusForKey('ELEVENLABS_API_KEY', stored) const deepgram = statusForKey('DEEPGRAM_API_KEY', stored) @@ -257,6 +313,7 @@ function buildStatus(stored: ProviderCredentialsMap): TranscriptionCredentialSta geminiLive, qwenRealtime, }, + elevenLabsMaxDurationSeconds: buildMaxDurationStatus(maxDurationSeconds), } } @@ -265,7 +322,10 @@ export async function getTranscriptionCredentialStatus( ): Promise { const settings = await readSettings(getSettingsFile(dataDir)) const stored = settings === null ? {} : readProviderCredentials(settings) - return buildStatus(stored) + const maxDurationSeconds = settings === null + ? undefined + : normalizeStoredMaxDuration(settings.elevenLabsMaxDurationSeconds) + return buildStatus(stored, maxDurationSeconds) } function normalizeOptionalSecret(value: string | null | undefined): string | null | undefined { @@ -318,6 +378,25 @@ function syncSettingsCredentialsOverlay(stored: ProviderCredentialsMap): void { setSettingsBackedCredentials(stored) } +/** Validates range/integer-ness; throws rather than letting a bad value reach ElevenLabs as a 422. */ +function applyMaxDurationPatch( + settings: Settings, + value: number | null | undefined +): Settings { + if (value === undefined) return settings + if (isMaxDurationEnvLocked()) { + throw new Error( + 'ELEVENLABS_MAX_DURATION_SECONDS is set by an environment variable and cannot be changed from Settings' + ) + } + if (value === null) { + const { elevenLabsMaxDurationSeconds: _drop, ...rest } = settings + return rest + } + resolveVoiceMaxDurationSeconds(String(value)) + return { ...settings, elevenLabsMaxDurationSeconds: value } +} + export async function updateTranscriptionCredentials( dataDir: string, update: TranscriptionCredentialsUpdate @@ -362,12 +441,16 @@ export async function updateTranscriptionCredentials( normalizeOptionalSecret(update.qwenRealtime) ) + const nextSettings = applyMaxDurationPatch(settings, update.elevenLabsMaxDurationSeconds) + const nextMaxDurationSeconds = normalizeStoredMaxDuration(nextSettings.elevenLabsMaxDurationSeconds) + return { - settings: { ...settings, providerCredentials: nextStored }, - result: nextStored, + settings: { ...nextSettings, providerCredentials: nextStored }, + result: { stored: nextStored, maxDurationSeconds: nextMaxDurationSeconds }, afterCommit: () => { syncSettingsCredentialsOverlay(nextStored) + settingsBackedMaxDurationSeconds = nextMaxDurationSeconds }, } - }).then((nextStored) => buildStatus(nextStored)) + }).then((next) => buildStatus(next.stored, next.maxDurationSeconds)) } diff --git a/hub/src/config/settings.ts b/hub/src/config/settings.ts index 4332e7a2b3..66a246773d 100644 --- a/hub/src/config/settings.ts +++ b/hub/src/config/settings.ts @@ -51,6 +51,11 @@ export interface Settings { * Env vars still win when set at process start (ops override). */ providerCredentials?: Partial> + /** + * Cap (seconds) on one ElevenLabs ConvAI conversation. Env + * ELEVENLABS_MAX_DURATION_SECONDS still wins when set at process start. + */ + elevenLabsMaxDurationSeconds?: number } export function getSettingsFile(dataDir: string): string { diff --git a/hub/src/web/routes/voice.test.ts b/hub/src/web/routes/voice.test.ts index 119bd34788..5f1532d1bf 100644 --- a/hub/src/web/routes/voice.test.ts +++ b/hub/src/web/routes/voice.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, mock, test, afterEach } from 'bun:test' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Hono } from 'hono' @@ -863,6 +863,7 @@ describe('transcription credentials onboarding', () => { 'TRANSCRIPTION_BASE_URL', 'TRANSCRIPTION_MODEL', 'TRANSCRIPTION_API_KEY', + 'ELEVENLABS_MAX_DURATION_SECONDS', ] as const const previous = new Map() let dir: string | null = null @@ -971,4 +972,99 @@ describe('transcription credentials onboarding', () => { } expect(saved.providerCredentials?.OPENAI_API_KEY).toBeUndefined() }) + + test('PUT elevenLabsMaxDurationSeconds persists and takes effect on the next token request without a restart', async () => { + clearManagedEnv() + dir = mkdtempSync(join(tmpdir(), 'hapi-voice-creds-maxdur-')) + writeFileSync(join(dir, 'settings.json'), JSON.stringify({})) + const app = createApp(dir) + const headers = { ...(await authHeaders()), 'content-type': 'application/json' } + + const defaultGet = await app.request('/api/voice/transcription/credentials', { headers }) + expect((await defaultGet.json() as { elevenLabsMaxDurationSeconds: { value: number; source: string } }).elevenLabsMaxDurationSeconds) + .toEqual({ value: 1800, source: 'none', editable: true }) + + const put = await app.request('/api/voice/transcription/credentials', { + method: 'PUT', + headers, + body: JSON.stringify({ elevenLabsMaxDurationSeconds: 3600 }), + }) + expect(put.status).toBe(200) + expect((await put.json() as { elevenLabsMaxDurationSeconds: { value: number; source: string } }).elevenLabsMaxDurationSeconds) + .toEqual({ value: 3600, source: 'settings', editable: true }) + + // Same process, no restart: the next token request's agent-creation payload + // must already reflect the new cap. + process.env.ELEVENLABS_API_KEY = 'maxdur-live-key' + const calls: Array<{ url: string; body: unknown }> = [] + const originalFetch = global.fetch + // @ts-expect-error test override + global.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + const body = init?.body ? JSON.parse(String(init.body)) : null + calls.push({ url, body }) + if (url.endsWith('/convai/agents') && init?.method === 'GET') { + return new Response(JSON.stringify({ agents: [] }), { status: 200 }) + } + if (url.endsWith('/convai/agents/create')) { + return new Response(JSON.stringify({ agent_id: 'agent_maxdur' }), { status: 200 }) + } + if (url.includes('/convai/conversation/token?agent_id=')) { + return new Response(JSON.stringify({ token: 'tok' }), { status: 200 }) + } + return new Response('not found', { status: 404 }) + }) as typeof fetch + + const tokenRes = await app.request('/api/voice/token', { + method: 'POST', + headers, + body: JSON.stringify({ voiceId: 'maxdur-voice' }), + }) + expect(tokenRes.status).toBe(200) + const create = calls.find((call) => call.url.endsWith('/convai/agents/create')) + expect((create?.body as any).conversation_config.conversation.max_duration_seconds).toBe(3600) + + global.fetch = originalFetch + }) + + test('PUT elevenLabsMaxDurationSeconds rejects out-of-range values', async () => { + clearManagedEnv() + dir = mkdtempSync(join(tmpdir(), 'hapi-voice-creds-maxdur-bad-')) + writeFileSync(join(dir, 'settings.json'), JSON.stringify({})) + const app = createApp(dir) + const headers = { ...(await authHeaders()), 'content-type': 'application/json' } + + const put = await app.request('/api/voice/transcription/credentials', { + method: 'PUT', + headers, + body: JSON.stringify({ elevenLabsMaxDurationSeconds: 30 }), + }) + expect(put.status).toBe(400) + const body = await put.json() as { error: string } + expect(body.error).toContain('must be an integer between 60 and 7200') + + const saved = JSON.parse(readFileSync(join(dir, 'settings.json'), 'utf8')) as { + elevenLabsMaxDurationSeconds?: number + } + expect(saved.elevenLabsMaxDurationSeconds).toBeUndefined() + }) + + test('PUT elevenLabsMaxDurationSeconds refuses to override an env-locked value', async () => { + clearManagedEnv() + process.env.ELEVENLABS_MAX_DURATION_SECONDS = '3600' + resetProviderCredentialEnvLocksForTests() + const { applyProviderCredentialsFromSettings } = await import('../../config/providerCredentials') + dir = mkdtempSync(join(tmpdir(), 'hapi-voice-creds-maxdur-locked-')) + writeFileSync(join(dir, 'settings.json'), JSON.stringify({})) + await applyProviderCredentialsFromSettings(dir) + + const app = createApp(dir) + const headers = { ...(await authHeaders()), 'content-type': 'application/json' } + const put = await app.request('/api/voice/transcription/credentials', { + method: 'PUT', + headers, + body: JSON.stringify({ elevenLabsMaxDurationSeconds: 1800 }), + }) + expect(put.status).toBe(409) + }) }) diff --git a/hub/src/web/routes/voice.ts b/hub/src/web/routes/voice.ts index faf1522fbe..ad6a789140 100644 --- a/hub/src/web/routes/voice.ts +++ b/hub/src/web/routes/voice.ts @@ -12,13 +12,13 @@ import { OPENAI_TRANSCRIPTION_MODEL, VOICE_AGENT_NAME, buildVoiceAgentConfig, - resolveVoiceMaxDurationSeconds, listConfiguredTranscriptionProviders, listConfiguredVoiceBackends, resolveHubVoiceBackend } from '@hapi/protocol/voice' import type { TranscriptionProvider, VoiceBackendType } from '@hapi/protocol/voice' import { + getConfiguredElevenLabsMaxDurationSeconds, getProviderEnvironment, getTranscriptionCredentialStatus, updateTranscriptionCredentials, @@ -257,7 +257,7 @@ const overridesEnsuredAgents = new Set() /** Configured ConvAI conversation cap; throws if ELEVENLABS_MAX_DURATION_SECONDS is invalid. */ function getMaxDurationSeconds(): number { - return resolveVoiceMaxDurationSeconds(process.env.ELEVENLABS_MAX_DURATION_SECONDS) + return getConfiguredElevenLabsMaxDurationSeconds() } interface ElevenLabsAgent { @@ -489,6 +489,7 @@ const transcriptionCredentialsUpdateSchema = z.object({ }).optional(), geminiLive: z.string().nullable().optional(), qwenRealtime: z.string().nullable().optional(), + elevenLabsMaxDurationSeconds: z.number().int().nullable().optional(), }).strict() export function createVoiceRoutes(options: { dataDir?: string } = {}): Hono { @@ -531,6 +532,9 @@ export function createVoiceRoutes(options: { dataDir?: string } = {}): Hono(null) + const [maxDurationMessage, setMaxDurationMessage] = useState(null) const [busy, setBusy] = useState(false) const [error, setError] = useState(null) const [message, setMessage] = useState(null) @@ -116,6 +125,7 @@ export function TranscriptionProviderOnboard(props: { if (next.openaiCompatible.modelEditable && next.openaiCompatible.model) { setModel(next.openaiCompatible.model) } + setMaxDurationInput(String(next.elevenLabsMaxDurationSeconds?.value ?? VOICE_MAX_DURATION_DEFAULT_SECONDS)) } catch { setStatus(null) } @@ -170,6 +180,7 @@ export function TranscriptionProviderOnboard(props: { if (next.openaiCompatible.modelEditable) { setModel(next.openaiCompatible.model ?? '') } + setMaxDurationInput(String(next.elevenLabsMaxDurationSeconds?.value ?? VOICE_MAX_DURATION_DEFAULT_SECONDS)) } const save = async () => { @@ -207,6 +218,53 @@ export function TranscriptionProviderOnboard(props: { } } + const maxDurationStatus = status?.elevenLabsMaxDurationSeconds + const maxDurationEditable = maxDurationStatus?.editable !== false + + const saveMaxDuration = async () => { + const parsed = Number(maxDurationInput) + if ( + !Number.isInteger(parsed) + || parsed < VOICE_MAX_DURATION_MIN_SECONDS + || parsed > VOICE_MAX_DURATION_MAX_SECONDS + ) { + setMaxDurationError(t('settings.voice.credentials.maxDuration.invalid', { + min: String(VOICE_MAX_DURATION_MIN_SECONDS), + max: String(VOICE_MAX_DURATION_MAX_SECONDS), + })) + return + } + setMaxDurationBusy(true) + setMaxDurationError(null) + setMaxDurationMessage(null) + try { + const next = await props.api.updateTranscriptionCredentials({ elevenLabsMaxDurationSeconds: parsed }) + setStatus(next) + setMaxDurationInput(String(next.elevenLabsMaxDurationSeconds?.value ?? parsed)) + setMaxDurationMessage(t('settings.voice.credentials.maxDuration.saved')) + } catch (err) { + setMaxDurationError(errorMessage(err, t('settings.voice.credentials.saveFailed'))) + } finally { + setMaxDurationBusy(false) + } + } + + const resetMaxDuration = async () => { + setMaxDurationBusy(true) + setMaxDurationError(null) + setMaxDurationMessage(null) + try { + const next = await props.api.updateTranscriptionCredentials({ elevenLabsMaxDurationSeconds: null }) + setStatus(next) + setMaxDurationInput(String(next.elevenLabsMaxDurationSeconds?.value ?? VOICE_MAX_DURATION_DEFAULT_SECONDS)) + setMaxDurationMessage(t('settings.voice.credentials.maxDuration.saved')) + } catch (err) { + setMaxDurationError(errorMessage(err, t('settings.voice.credentials.saveFailed'))) + } finally { + setMaxDurationBusy(false) + } + } + return (

@@ -288,6 +346,42 @@ export function TranscriptionProviderOnboard(props: {

{t('settings.voice.credentials.envLocked')}

) : null} + {provider === 'elevenlabs' ? ( +
+ +

{t('settings.voice.credentials.maxDuration.hint')}

+ {!maxDurationEditable ? ( +

{t('settings.voice.credentials.envLocked')}

+ ) : null} + {maxDurationError ?

{maxDurationError}

: null} + {maxDurationMessage ?

{maxDurationMessage}

: null} +
+ + {maxDurationStatus?.source === 'settings' ? ( + + ) : null} +
+
+ ) : null} + {error ?

{error}

: null} {message ?

{message}

: null} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 7344164495..1ba91a318b 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -986,6 +986,12 @@ export default { 'settings.voice.credentials.source.env': 'environment', 'settings.voice.credentials.source.settings': 'Settings', 'settings.voice.credentials.envLocked': 'This key comes from the hub environment and cannot be changed here.', + 'settings.voice.credentials.maxDuration.label': 'Max conversation length (seconds)', + 'settings.voice.credentials.maxDuration.hint': 'Caps how long a single ElevenLabs voice conversation can run. ElevenLabs applies a 600s default on new agents if this is not set; the hub default is 1800s. Range: {min}-{max}.', + 'settings.voice.credentials.maxDuration.invalid': 'Enter a whole number of seconds between {min} and {max}.', + 'settings.voice.credentials.maxDuration.save': 'Save duration', + 'settings.voice.credentials.maxDuration.reset': 'Reset to default', + 'settings.voice.credentials.maxDuration.saved': 'Saved. Applies to new and existing hub-managed agents.', 'settings.voice.credentials.manage': 'Add or manage providers', 'settings.voice.credentials.manageHint': 'Add OpenAI, ElevenLabs, or Groq to enable dictation on the hub.', 'settings.voice.credentials.manageAssistantHint': 'Paste an API key to enable ElevenLabs, Gemini Live, or Qwen Realtime on the hub.', diff --git a/web/src/lib/locales/ru.ts b/web/src/lib/locales/ru.ts index c3d0391011..9204529e09 100644 --- a/web/src/lib/locales/ru.ts +++ b/web/src/lib/locales/ru.ts @@ -988,6 +988,12 @@ export default { 'settings.voice.credentials.source.env': 'окружение', 'settings.voice.credentials.source.settings': 'Настройки', 'settings.voice.credentials.envLocked': 'Этот ключ задан окружением хаба и не может быть изменён здесь.', + 'settings.voice.credentials.maxDuration.label': 'Макс. длительность разговора (сек.)', + 'settings.voice.credentials.maxDuration.hint': 'Ограничивает длительность одного голосового разговора ElevenLabs. Если не задано, ElevenLabs применяет к новым агентам значение по умолчанию 600 с; по умолчанию в хабе — 1800 с. Диапазон: {min}-{max}.', + 'settings.voice.credentials.maxDuration.invalid': 'Введите целое число секунд от {min} до {max}.', + 'settings.voice.credentials.maxDuration.save': 'Сохранить длительность', + 'settings.voice.credentials.maxDuration.reset': 'Сбросить по умолчанию', + 'settings.voice.credentials.maxDuration.saved': 'Сохранено. Применяется к новым и существующим агентам хаба.', 'settings.voice.credentials.manage': 'Добавить или управлять провайдерами', 'settings.voice.credentials.manageHint': 'Добавьте OpenAI, ElevenLabs или Groq, чтобы включить диктовку на хабе.', 'settings.voice.credentials.manageAssistantHint': 'Вставьте API-ключ, чтобы включить ElevenLabs, Gemini Live или Qwen Realtime на хабе.', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 73b2f73083..18f7cf2519 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -984,6 +984,12 @@ export default { 'settings.voice.credentials.source.env': '环境变量', 'settings.voice.credentials.source.settings': '设置', 'settings.voice.credentials.envLocked': '该密钥来自 Hub 环境变量,无法在此修改。', + 'settings.voice.credentials.maxDuration.label': '单次对话最长时长(秒)', + 'settings.voice.credentials.maxDuration.hint': '限制单次 ElevenLabs 语音对话的最长时长。若不设置,ElevenLabs 会对新建的智能体应用 600 秒的默认值;Hub 默认值为 1800 秒。范围:{min}-{max}。', + 'settings.voice.credentials.maxDuration.invalid': '请输入 {min} 到 {max} 之间的整数秒数。', + 'settings.voice.credentials.maxDuration.save': '保存时长', + 'settings.voice.credentials.maxDuration.reset': '恢复默认值', + 'settings.voice.credentials.maxDuration.saved': '已保存,适用于新建及现有的 Hub 托管智能体。', 'settings.voice.credentials.manage': '添加或管理提供商', 'settings.voice.credentials.manageHint': '添加 OpenAI、ElevenLabs 或 Groq,即可在 Hub 上启用语音转文字。', 'settings.voice.credentials.manageAssistantHint': '粘贴 API 密钥以在 Hub 上启用 ElevenLabs、Gemini Live 或 Qwen Realtime。', From 89e3b5c65f9ad768ee72c68e7b0c4b5b7e9f535b Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:55:30 +0000 Subject: [PATCH 3/5] fix(voice): reconcile max duration on cached agents, not just once per process Codex review on heavygee/hapi#230 caught this: getOrCreateAgentIdForVoice's agentIdCache short-circuited before ever reaching ensureAgentOverrides, and ensureAgentOverrides's overridesEnsuredAgents gate was a one-time-forever marker that didn't account for the configured duration changing after the first reconciliation. Together, any voice already used once in the hub process would keep its old cap until a restart, contradicting the Settings UI's 'takes effect immediately' claim. Track the last-applied max duration per agent and compare it against the live setting on every call, including agentIdCache hits, so a Settings change reaches an already-cached agent on its very next token request. Co-Authored-By: Claude Sonnet 5.5 --- hub/src/web/routes/voice.test.ts | 41 ++++++++++++++++++++++++++++++-- hub/src/web/routes/voice.ts | 34 +++++++++++++++++++------- 2 files changed, 65 insertions(+), 10 deletions(-) diff --git a/hub/src/web/routes/voice.test.ts b/hub/src/web/routes/voice.test.ts index 5f1532d1bf..c5edfc64e5 100644 --- a/hub/src/web/routes/voice.test.ts +++ b/hub/src/web/routes/voice.test.ts @@ -996,19 +996,22 @@ describe('transcription credentials onboarding', () => { // Same process, no restart: the next token request's agent-creation payload // must already reflect the new cap. process.env.ELEVENLABS_API_KEY = 'maxdur-live-key' - const calls: Array<{ url: string; body: unknown }> = [] + const calls: Array<{ url: string; method?: string; body: unknown }> = [] const originalFetch = global.fetch // @ts-expect-error test override global.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input) const body = init?.body ? JSON.parse(String(init.body)) : null - calls.push({ url, body }) + calls.push({ url, method: init?.method, body }) if (url.endsWith('/convai/agents') && init?.method === 'GET') { return new Response(JSON.stringify({ agents: [] }), { status: 200 }) } if (url.endsWith('/convai/agents/create')) { return new Response(JSON.stringify({ agent_id: 'agent_maxdur' }), { status: 200 }) } + if (url.endsWith('/convai/agents/agent_maxdur') && init?.method === 'PATCH') { + return new Response(JSON.stringify({ agent_id: 'agent_maxdur' }), { status: 200 }) + } if (url.includes('/convai/conversation/token?agent_id=')) { return new Response(JSON.stringify({ token: 'tok' }), { status: 200 }) } @@ -1024,6 +1027,40 @@ describe('transcription credentials onboarding', () => { const create = calls.find((call) => call.url.endsWith('/convai/agents/create')) expect((create?.body as any).conversation_config.conversation.max_duration_seconds).toBe(3600) + // Regression for a real finding: the in-process agent-id cache must not + // shield an already-used agent from a later Settings change. Change the + // cap again and request a token for the SAME voice (now cached) a second + // time — the cached agent must be PATCHed to the new cap, not left stale + // until a hub restart. + calls.length = 0 + const secondPut = await app.request('/api/voice/transcription/credentials', { + method: 'PUT', + headers, + body: JSON.stringify({ elevenLabsMaxDurationSeconds: 900 }), + }) + expect(secondPut.status).toBe(200) + + const secondTokenRes = await app.request('/api/voice/token', { + method: 'POST', + headers, + body: JSON.stringify({ voiceId: 'maxdur-voice' }), + }) + expect(secondTokenRes.status).toBe(200) + expect(calls.some((call) => call.url.endsWith('/convai/agents/create'))).toBe(false) + expect(calls.some((call) => call.url.endsWith('/convai/agents') && call.method === 'GET')).toBe(false) + const patch = calls.find((call) => call.method === 'PATCH') + expect((patch?.body as any)?.conversation_config?.conversation?.max_duration_seconds).toBe(900) + + // A third request for the same (now up-to-date) voice must not re-PATCH. + calls.length = 0 + const thirdTokenRes = await app.request('/api/voice/token', { + method: 'POST', + headers, + body: JSON.stringify({ voiceId: 'maxdur-voice' }), + }) + expect(thirdTokenRes.status).toBe(200) + expect(calls.some((call) => call.method === 'PATCH')).toBe(false) + global.fetch = originalFetch }) diff --git a/hub/src/web/routes/voice.ts b/hub/src/web/routes/voice.ts index ad6a789140..4c6858eef4 100644 --- a/hub/src/web/routes/voice.ts +++ b/hub/src/web/routes/voice.ts @@ -255,6 +255,12 @@ const agentIdCache = new Map() // reconciled with the canonical buildVoiceAgentConfig() shape. Reset on process restart. const overridesEnsuredAgents = new Set() +// Per-agent max_duration_seconds last confirmed applied. Compared against the live +// setting on every call (even for agentIdCache hits) so a Settings change reaches +// an already-cached agent on its next token request instead of waiting for a +// process restart to clear the caches above. +const reconciledMaxDurationByAgent = new Map() + /** Configured ConvAI conversation cap; throws if ELEVENLABS_MAX_DURATION_SECONDS is invalid. */ function getMaxDurationSeconds(): number { return getConfiguredElevenLabsMaxDurationSeconds() @@ -281,13 +287,16 @@ async function ensureAgentOverrides( agentId: string, options: { reconcileMaxDuration?: boolean } = {} ): Promise { - if (overridesEnsuredAgents.has(agentId)) return - overridesEnsuredAgents.add(agentId) - // The conversation cap is only enforced on agents the hub itself manages // (found/created by name); operator-supplied agents keep their own cap. + const desiredMaxDuration = options.reconcileMaxDuration ? getMaxDurationSeconds() : undefined + const overridesNeeded = !overridesEnsuredAgents.has(agentId) + const maxDurationNeeded = desiredMaxDuration !== undefined + && reconciledMaxDurationByAgent.get(agentId) !== desiredMaxDuration + if (!overridesNeeded && !maxDurationNeeded) return + const canonicalConfig = buildVoiceAgentConfig( - options.reconcileMaxDuration ? { maxDurationSeconds: getMaxDurationSeconds() } : {} + desiredMaxDuration !== undefined ? { maxDurationSeconds: desiredMaxDuration } : {} ) const canonical = canonicalConfig.platform_settings if (!canonical) return @@ -324,17 +333,19 @@ async function ensureAgentOverrides( status: response.status, errorMessage }) - overridesEnsuredAgents.delete(agentId) return } + // Only mark state once the PATCH actually succeeds, so a failed attempt + // retries on the next call instead of being silently assumed current. + overridesEnsuredAgents.add(agentId) + if (desiredMaxDuration !== undefined) reconciledMaxDurationByAgent.set(agentId, desiredMaxDuration) console.log('[Voice] Reconciled platform_settings.overrides and max duration on agent', { agentId }) } catch (error) { console.warn('[Voice] Error reconciling agent overrides (non-fatal)', { agentId, error: error instanceof Error ? error.message : String(error) }) - overridesEnsuredAgents.delete(agentId) } } @@ -440,6 +451,10 @@ async function getOrCreateAgentIdForVoice(apiKey: string, voiceId?: string): Pro const cacheKey = `${apiKey.slice(0, 4)}...${apiKey.slice(-4)}::${voiceId ?? 'default'}` const cached = agentIdCache.get(cacheKey) if (cached) { + // Still check the live max-duration setting against a cached agent — a + // Settings change must reach it on the next request, not wait for a + // process restart to clear this cache. + await ensureAgentOverrides(apiKey, cached, { reconcileMaxDuration: true }) return cached } @@ -460,12 +475,15 @@ async function getOrCreateAgentIdForVoice(apiKey: string, voiceId?: string): Pro } else { // Create new agent console.log('[Voice] No existing agent found, creating new one...') + const maxDurationAtCreation = getMaxDurationSeconds() agentId = await createNamedHapiAgent(apiKey, agentName, voiceId) if (agentId) { console.log('[Voice] Created new agent:', agentId) - // Newly-created agents already carry the canonical overrides, but - // mark as ensured so we don't re-PATCH on every token request. + // Newly-created agents already carry the canonical overrides and the + // cap in effect at creation time; record both so a later Settings + // change is detected as drift instead of assumed current forever. overridesEnsuredAgents.add(agentId) + reconciledMaxDurationByAgent.set(agentId, maxDurationAtCreation) } } From e66a44910e2241a4e4f7b1d66e1709565eaf69db Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:47:28 +0000 Subject: [PATCH 4/5] fix(voice): address tiann/hapi#2017 review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix TS2769 typecheck failure: test assertions casting the credentials response narrowed the type to omit 'editable', which upstream CI's bun typecheck catches and this fork's (currently broken) CI did not. - An invalid ELEVENLABS_MAX_DURATION_SECONDS env value threw out of buildMaxDurationStatus(), which is shared by every provider's GET/PUT credentials status — breaking visibility into OpenAI/Deepgram/ Groq settings too, not just ElevenLabs. The token-issuance hot path (getConfiguredElevenLabsMaxDurationSeconds) still throws there by design; only the status/update endpoints now degrade gracefully. Co-Authored-By: Claude Sonnet 5.5 --- hub/src/config/providerCredentials.test.ts | 30 ++++++++++++++++++++++ hub/src/config/providerCredentials.ts | 16 +++++++++--- hub/src/web/routes/voice.test.ts | 4 +-- 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/hub/src/config/providerCredentials.test.ts b/hub/src/config/providerCredentials.test.ts index 50c6d84489..8deb44384a 100644 --- a/hub/src/config/providerCredentials.test.ts +++ b/hub/src/config/providerCredentials.test.ts @@ -507,6 +507,36 @@ describe('providerCredentials', () => { }) }) + it('does not let an invalid env value break status for every other provider', async () => { + dir = makeTempDir() + writeFileSync(join(dir, 'settings.json'), JSON.stringify({ + providerCredentials: { OPENAI_API_KEY: 'sk-live-openai' } + })) + process.env.ELEVENLABS_MAX_DURATION_SECONDS = 'not-a-number' + await applyProviderCredentialsFromSettings(dir) + + // The hot path (actually issuing a voice token) must still fail loudly. + expect(() => getConfiguredElevenLabsMaxDurationSeconds()).toThrow(/must be an integer between/) + + // But viewing/updating credentials status is shared by every provider and + // must not 500 just because ElevenLabs' duration env var is misconfigured. + const status = await getTranscriptionCredentialStatus(dir) + expect(status.elevenLabsMaxDurationSeconds).toEqual({ + value: 1800, + source: 'env', + editable: false, + }) + expect(status.openai).toEqual({ + configured: true, + source: 'settings', + hint: '••••enai', + editable: true, + }) + + const updated = await updateTranscriptionCredentials(dir, { groq: 'gsk-live-groq' }) + expect(updated.groq.configured).toBe(true) + }) + it('ignores a stale out-of-range value already on disk rather than crashing', async () => { dir = makeTempDir() writeFileSync(join(dir, 'settings.json'), JSON.stringify({ elevenLabsMaxDurationSeconds: 9999 })) diff --git a/hub/src/config/providerCredentials.ts b/hub/src/config/providerCredentials.ts index 3818eb6df2..5f47ca643a 100644 --- a/hub/src/config/providerCredentials.ts +++ b/hub/src/config/providerCredentials.ts @@ -123,10 +123,18 @@ export function getConfiguredElevenLabsMaxDurationSeconds(): number { function buildMaxDurationStatus(maxDurationSeconds: number | undefined): MaxDurationStatus { if (isMaxDurationEnvLocked()) { - return { - value: resolveVoiceMaxDurationSeconds(process.env.ELEVENLABS_MAX_DURATION_SECONDS), - source: 'env', - editable: false, + // A misconfigured env value still throws where it must (token issuance, + // via getConfiguredElevenLabsMaxDurationSeconds) so voice stays broken + // loudly until fixed. Here it must not take down the status/update + // endpoints shared by every other provider's credentials. + try { + return { + value: resolveVoiceMaxDurationSeconds(process.env.ELEVENLABS_MAX_DURATION_SECONDS), + source: 'env', + editable: false, + } + } catch { + return { value: VOICE_MAX_DURATION_DEFAULT_SECONDS, source: 'env', editable: false } } } if (maxDurationSeconds !== undefined) { diff --git a/hub/src/web/routes/voice.test.ts b/hub/src/web/routes/voice.test.ts index c5edfc64e5..a5df41cc7f 100644 --- a/hub/src/web/routes/voice.test.ts +++ b/hub/src/web/routes/voice.test.ts @@ -981,7 +981,7 @@ describe('transcription credentials onboarding', () => { const headers = { ...(await authHeaders()), 'content-type': 'application/json' } const defaultGet = await app.request('/api/voice/transcription/credentials', { headers }) - expect((await defaultGet.json() as { elevenLabsMaxDurationSeconds: { value: number; source: string } }).elevenLabsMaxDurationSeconds) + expect((await defaultGet.json() as { elevenLabsMaxDurationSeconds: { value: number; source: string; editable: boolean } }).elevenLabsMaxDurationSeconds) .toEqual({ value: 1800, source: 'none', editable: true }) const put = await app.request('/api/voice/transcription/credentials', { @@ -990,7 +990,7 @@ describe('transcription credentials onboarding', () => { body: JSON.stringify({ elevenLabsMaxDurationSeconds: 3600 }), }) expect(put.status).toBe(200) - expect((await put.json() as { elevenLabsMaxDurationSeconds: { value: number; source: string } }).elevenLabsMaxDurationSeconds) + expect((await put.json() as { elevenLabsMaxDurationSeconds: { value: number; source: string; editable: boolean } }).elevenLabsMaxDurationSeconds) .toEqual({ value: 3600, source: 'settings', editable: true }) // Same process, no restart: the next token request's agent-creation payload From a1ef87cb1c5a08634891da16b9affe5a13a9ada1 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:54:45 +0000 Subject: [PATCH 5/5] fix(voice): interpolate duration range in the Settings hint Nit from tiann/hapi#2017 follow-up review: the hint under the max-duration field rendered the literal '{min}-{max}' placeholders instead of '60-7200'. Co-Authored-By: Claude Sonnet 5.5 --- .../components/settings/TranscriptionProviderOnboard.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/web/src/components/settings/TranscriptionProviderOnboard.tsx b/web/src/components/settings/TranscriptionProviderOnboard.tsx index 63f0707892..97126ac35c 100644 --- a/web/src/components/settings/TranscriptionProviderOnboard.tsx +++ b/web/src/components/settings/TranscriptionProviderOnboard.tsx @@ -363,7 +363,12 @@ export function TranscriptionProviderOnboard(props: { className="w-full rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] px-2 py-1.5 text-sm text-[var(--app-fg)]" /> -

{t('settings.voice.credentials.maxDuration.hint')}

+

+ {t('settings.voice.credentials.maxDuration.hint', { + min: String(VOICE_MAX_DURATION_MIN_SECONDS), + max: String(VOICE_MAX_DURATION_MAX_SECONDS), + })} +

{!maxDurationEditable ? (

{t('settings.voice.credentials.envLocked')}

) : null}