mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-07 01:21:37 +00:00
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * fix(voice): address tiann/hapi#2017 review findings - 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
121 lines
4.6 KiB
TypeScript
121 lines
4.6 KiB
TypeScript
import { describe, expect, test } from 'bun:test'
|
|
import {
|
|
buildVoiceAgentConfig,
|
|
listConfiguredTranscriptionProviders,
|
|
listConfiguredVoiceBackends,
|
|
resolveEffectiveVoiceBackend,
|
|
resolveVoiceMaxDurationSeconds,
|
|
resolveHubVoiceBackend
|
|
} from './voice'
|
|
|
|
describe('listConfiguredTranscriptionProviders', () => {
|
|
test('returns configured providers with honest mode capabilities', () => {
|
|
expect(listConfiguredTranscriptionProviders({
|
|
OPENAI_API_KEY: 'openai',
|
|
ELEVENLABS_API_KEY: 'elevenlabs',
|
|
DEEPGRAM_API_KEY: 'deepgram',
|
|
GROQ_API_KEY: 'groq',
|
|
TRANSCRIPTION_BASE_URL: 'http://localhost:8000/v1',
|
|
TRANSCRIPTION_MODEL: 'whisper-large-v3'
|
|
})).toEqual([
|
|
{ id: 'openai', label: 'OpenAI', modes: ['standard', 'realtime'] },
|
|
{ id: 'elevenlabs', label: 'ElevenLabs', modes: ['standard', 'realtime'] },
|
|
{ id: 'deepgram', label: 'Deepgram', modes: ['standard', 'realtime'] },
|
|
{ id: 'groq', label: 'Groq', modes: ['standard'] },
|
|
{ id: 'openai-compatible', label: 'OpenAI-compatible / local', modes: ['standard'] }
|
|
])
|
|
})
|
|
|
|
test('does not advertise incomplete or missing configuration', () => {
|
|
expect(listConfiguredTranscriptionProviders({ TRANSCRIPTION_BASE_URL: 'http://localhost:8000/v1' })).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('listConfiguredVoiceBackends', () => {
|
|
test('returns only backends with API keys', () => {
|
|
const backends = listConfiguredVoiceBackends({
|
|
ELEVENLABS_API_KEY: 'el',
|
|
GEMINI_API_KEY: 'gm',
|
|
DASHSCOPE_API_KEY: 'qw'
|
|
})
|
|
expect(backends).toEqual(['elevenlabs', 'gemini-live', 'qwen-realtime'])
|
|
})
|
|
|
|
test('returns empty when no keys configured', () => {
|
|
expect(listConfiguredVoiceBackends({})).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('buildVoiceAgentConfig', () => {
|
|
test('includes ElevenLabs session context placeholder for dynamicVariables', () => {
|
|
const prompt = buildVoiceAgentConfig().conversation_config.agent.prompt.prompt
|
|
expect(prompt).toContain('{{initialConversationContext}}')
|
|
})
|
|
})
|
|
|
|
describe('resolveHubVoiceBackend', () => {
|
|
test('uses VOICE_BACKEND when that backend is configured', () => {
|
|
const backend = resolveHubVoiceBackend({
|
|
VOICE_BACKEND: 'gemini-live',
|
|
GEMINI_API_KEY: 'gm',
|
|
ELEVENLABS_API_KEY: 'el'
|
|
})
|
|
expect(backend).toBe('gemini-live')
|
|
})
|
|
|
|
test('falls back to first configured when VOICE_BACKEND unavailable', () => {
|
|
const backend = resolveHubVoiceBackend({
|
|
VOICE_BACKEND: 'qwen-realtime',
|
|
ELEVENLABS_API_KEY: 'el'
|
|
})
|
|
expect(backend).toBe('elevenlabs')
|
|
})
|
|
|
|
test('returns null when no backends configured', () => {
|
|
expect(resolveHubVoiceBackend({})).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('resolveEffectiveVoiceBackend', () => {
|
|
const configured = ['elevenlabs', 'gemini-live'] as const
|
|
|
|
test('prefers stored preference when configured', () => {
|
|
expect(resolveEffectiveVoiceBackend(configured, 'gemini-live', 'elevenlabs')).toBe('elevenlabs')
|
|
})
|
|
|
|
test('uses hub default when preference missing or invalid', () => {
|
|
expect(resolveEffectiveVoiceBackend(configured, 'gemini-live', null)).toBe('gemini-live')
|
|
expect(resolveEffectiveVoiceBackend(configured, 'gemini-live', 'qwen-realtime')).toBe('gemini-live')
|
|
})
|
|
|
|
test('returns null when no backends configured', () => {
|
|
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 })
|
|
})
|
|
})
|