Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/guide/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
8 changes: 8 additions & 0 deletions docs/guide/voice-assistant.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<id>]` 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
Expand Down
132 changes: 132 additions & 0 deletions hub/src/config/providerCredentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
applyProviderCredentialsFromSettings,
getConfiguredElevenLabsMaxDurationSeconds,
getProviderEnvironment,
getTranscriptionCredentialStatus,
maskSecret,
Expand All @@ -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 {
Expand Down Expand Up @@ -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')
})
})
})
101 changes: 96 additions & 5 deletions hub/src/config/providerCredentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand All @@ -59,6 +66,7 @@ export interface TranscriptionCredentialStatus {
geminiLive: MaskedCredentialStatus
qwenRealtime: MaskedCredentialStatus
}
elevenLabsMaxDurationSeconds: MaxDurationStatus
}

export interface TranscriptionCredentialsUpdate {
Expand All @@ -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<Record<ProviderCredentialEnvKey, string>>

let envLockedKeys = new Set<ProviderCredentialEnvKey>()
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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -257,6 +321,7 @@ function buildStatus(stored: ProviderCredentialsMap): TranscriptionCredentialSta
geminiLive,
qwenRealtime,
},
elevenLabsMaxDurationSeconds: buildMaxDurationStatus(maxDurationSeconds),
}
}

Expand All @@ -265,7 +330,10 @@ export async function getTranscriptionCredentialStatus(
): Promise<TranscriptionCredentialStatus> {
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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))
}
5 changes: 5 additions & 0 deletions hub/src/config/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ export interface Settings {
* Env vars still win when set at process start (ops override).
*/
providerCredentials?: Partial<Record<string, string>>
/**
* 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 {
Expand Down
Loading