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..f0f1ba44c3 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 in Settings → Voice → manage providers (hub owner only), under the ElevenLabs entry, or with: + +```bash +export ELEVENLABS_MAX_DURATION_SECONDS=3600 # 60-7200 +``` + +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: ```bash diff --git a/hub/src/config/providerCredentials.test.ts b/hub/src/config/providerCredentials.test.ts index b90df55c48..8deb44384a 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,134 @@ 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('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 })) + 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..5f47ca643a 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,66 @@ 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()) { + // 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) { + 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 +279,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 +321,7 @@ function buildStatus(stored: ProviderCredentialsMap): TranscriptionCredentialSta geminiLive, qwenRealtime, }, + elevenLabsMaxDurationSeconds: buildMaxDurationStatus(maxDurationSeconds), } } @@ -265,7 +330,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 +386,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 +449,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 729203133c..a5df41cc7f 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' @@ -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, @@ -754,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 @@ -862,4 +972,136 @@ 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; editable: boolean } }).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; editable: boolean } }).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; 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, 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 }) + } + 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) + + // 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 + }) + + 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 da298594b1..4c6858eef4 100644 --- a/hub/src/web/routes/voice.ts +++ b/hub/src/web/routes/voice.ts @@ -18,6 +18,7 @@ import { } from '@hapi/protocol/voice' import type { TranscriptionProvider, VoiceBackendType } from '@hapi/protocol/voice' import { + getConfiguredElevenLabsMaxDurationSeconds, getProviderEnvironment, getTranscriptionCredentialStatus, updateTranscriptionCredentials, @@ -254,6 +255,17 @@ 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() +} + interface ElevenLabsAgent { agent_id: string name: string @@ -270,11 +282,23 @@ interface ElevenLabsAgent { * * See: https://elevenlabs.io/docs/agents-platform/customization/personalization/overrides */ -async function ensureAgentOverrides(apiKey: string, agentId: string): Promise { - if (overridesEnsuredAgents.has(agentId)) return - overridesEnsuredAgents.add(agentId) - - const canonical = buildVoiceAgentConfig().platform_settings +async function ensureAgentOverrides( + apiKey: string, + agentId: string, + options: { reconcileMaxDuration?: boolean } = {} +): Promise { + // 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( + desiredMaxDuration !== undefined ? { maxDurationSeconds: desiredMaxDuration } : {} + ) + const canonical = canonicalConfig.platform_settings if (!canonical) return try { @@ -287,7 +311,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 @@ -420,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 } @@ -436,16 +471,19 @@ 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...') + 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) } } @@ -469,6 +507,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 { @@ -511,6 +550,9 @@ 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', diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 5010d101ba..18db152f27 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -97,6 +97,12 @@ export interface TranscriptionCredentialStatus { geminiLive: MaskedCredentialStatus qwenRealtime: MaskedCredentialStatus } + /** Optional: older hub versions won't send this. */ + elevenLabsMaxDurationSeconds?: { + value: number + source: ProviderCredentialSource + editable: boolean + } } export interface TranscriptionCredentialsUpdate { @@ -111,6 +117,8 @@ export interface TranscriptionCredentialsUpdate { } geminiLive?: string | null qwenRealtime?: string | null + /** null resets to the hub default (1800s) */ + elevenLabsMaxDurationSeconds?: number | null } type ApiClientOptions = { diff --git a/web/src/components/settings/TranscriptionProviderOnboard.tsx b/web/src/components/settings/TranscriptionProviderOnboard.tsx index a374011b90..97126ac35c 100644 --- a/web/src/components/settings/TranscriptionProviderOnboard.tsx +++ b/web/src/components/settings/TranscriptionProviderOnboard.tsx @@ -3,6 +3,11 @@ import { ApiError, type ApiClient, type TranscriptionCredentialStatus, type Tran import { useTranslation } from '@/lib/use-translation' import { SelectControl } from '@/components/ui/select-control' import { Button } from '@/components/ui/button' +import { + VOICE_MAX_DURATION_DEFAULT_SECONDS, + VOICE_MAX_DURATION_MAX_SECONDS, + VOICE_MAX_DURATION_MIN_SECONDS +} from '@hapi/protocol/voice' import { DICTATION_PROVIDER_PRESETS, dictationOnboardProviders, hasOpenAICompatibleCredentials, type DictationProviderPreset } from './transcriptionProviders' type DictationProvider = DictationProviderPreset | 'deepgram' | 'openai-compatible' @@ -79,6 +84,10 @@ export function TranscriptionProviderOnboard(props: { const [apiKey, setApiKey] = useState('') const [baseUrl, setBaseUrl] = useState('') const [model, setModel] = useState('') + const [maxDurationInput, setMaxDurationInput] = useState('') + const [maxDurationBusy, setMaxDurationBusy] = useState(false) + const [maxDurationError, setMaxDurationError] = useState(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,47 @@ export function TranscriptionProviderOnboard(props: {

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

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

+ {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} + {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。',