vellum 0.2.12 → 0.2.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bun.lock +2 -2
- package/package.json +2 -2
- package/src/__tests__/call-orchestrator.test.ts +58 -0
- package/src/__tests__/config-schema.test.ts +278 -0
- package/src/__tests__/elevenlabs-client.test.ts +209 -0
- package/src/__tests__/trust-store.test.ts +1 -0
- package/src/__tests__/twilio-routes-twiml.test.ts +127 -0
- package/src/calls/call-orchestrator.ts +3 -1
- package/src/calls/elevenlabs-client.ts +89 -0
- package/src/calls/elevenlabs-config.ts +29 -0
- package/src/calls/twilio-routes.ts +55 -6
- package/src/calls/voice-quality.ts +92 -0
- package/src/config/bundled-skills/phone-calls/SKILL.md +81 -1
- package/src/config/defaults.ts +18 -0
- package/src/config/schema.ts +110 -0
- package/src/config/types.ts +2 -0
- package/src/permissions/defaults.ts +11 -0
- package/src/runtime/routes/conversation-routes.ts +12 -5
package/bun.lock
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"@huggingface/transformers": "^3.8.1",
|
|
12
12
|
"@qdrant/js-client-rest": "^1.16.2",
|
|
13
13
|
"@sentry/node": "^10.38.0",
|
|
14
|
-
"@vellumai/cli": "0.1.
|
|
14
|
+
"@vellumai/cli": "0.1.13",
|
|
15
15
|
"@vellumai/vellum-gateway": "0.1.10",
|
|
16
16
|
"agentmail": "^0.1.0",
|
|
17
17
|
"archiver": "^7.0.1",
|
|
@@ -542,7 +542,7 @@
|
|
|
542
542
|
|
|
543
543
|
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.56.0", "", { "dependencies": { "@typescript-eslint/types": "8.56.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg=="],
|
|
544
544
|
|
|
545
|
-
"@vellumai/cli": ["@vellumai/cli@0.1.
|
|
545
|
+
"@vellumai/cli": ["@vellumai/cli@0.1.13", "", { "dependencies": { "ink": "^6.7.0", "react": "^19.2.4", "react-devtools-core": "^6.1.2" }, "bin": { "vellum-cli": "src/index.ts" } }, "sha512-zoES1ddavpHZljC/uPkelJvIsen77aTtFwfLYAA4zdtCCf3zktS2+TaDVQxB1Eh+dbRi3Tgc+XCXbq7S86kFiQ=="],
|
|
546
546
|
|
|
547
547
|
"@vellumai/vellum-gateway": ["@vellumai/vellum-gateway@0.1.10", "", { "dependencies": { "file-type": "^21.3.0", "pino": "^9.6.0", "pino-pretty": "^13.1.3", "zod": "^4.3.6" } }, "sha512-a41fGexW8RpWL4RTfZ3EM+XJMvz7t26D1axu2xAtZioXW3ZWMLGuogHnIJsgglzESl49E6VmmUsUGeD+dseV2w=="],
|
|
548
548
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vellum",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.13",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"vellum": "./src/index.ts"
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"@huggingface/transformers": "^3.8.1",
|
|
30
30
|
"@qdrant/js-client-rest": "^1.16.2",
|
|
31
31
|
"@sentry/node": "^10.38.0",
|
|
32
|
-
"@vellumai/cli": "0.1.
|
|
32
|
+
"@vellumai/cli": "0.1.13",
|
|
33
33
|
"@vellumai/vellum-gateway": "0.1.10",
|
|
34
34
|
"agentmail": "^0.1.0",
|
|
35
35
|
"archiver": "^7.0.1",
|
|
@@ -29,6 +29,8 @@ mock.module('../util/logger.js', () => ({
|
|
|
29
29
|
|
|
30
30
|
// ── Config mock ─────────────────────────────────────────────────────
|
|
31
31
|
|
|
32
|
+
let mockCallModel: string | undefined = undefined;
|
|
33
|
+
|
|
32
34
|
mock.module('../config/loader.js', () => ({
|
|
33
35
|
getConfig: () => ({
|
|
34
36
|
apiKeys: { anthropic: 'test-key' },
|
|
@@ -41,6 +43,7 @@ mock.module('../config/loader.js', () => ({
|
|
|
41
43
|
silenceTimeoutSeconds: 30,
|
|
42
44
|
disclosure: { enabled: false, text: '' },
|
|
43
45
|
safety: { denyCategories: [] },
|
|
46
|
+
model: mockCallModel,
|
|
44
47
|
},
|
|
45
48
|
}),
|
|
46
49
|
}));
|
|
@@ -192,6 +195,7 @@ function setupOrchestrator(task?: string) {
|
|
|
192
195
|
describe('call-orchestrator', () => {
|
|
193
196
|
beforeEach(() => {
|
|
194
197
|
resetTables();
|
|
198
|
+
mockCallModel = undefined;
|
|
195
199
|
// Reset the stream mock to default behaviour
|
|
196
200
|
mockStreamFn.mockImplementation(() => createMockStream(['Hello', ' there']));
|
|
197
201
|
});
|
|
@@ -451,4 +455,58 @@ describe('call-orchestrator', () => {
|
|
|
451
455
|
// Second destroy should not throw
|
|
452
456
|
expect(() => orchestrator.destroy()).not.toThrow();
|
|
453
457
|
});
|
|
458
|
+
|
|
459
|
+
// ── Model override from config ──────────────────────────────────────
|
|
460
|
+
|
|
461
|
+
test('uses default model when calls.model is not set', async () => {
|
|
462
|
+
mockCallModel = undefined;
|
|
463
|
+
mockStreamFn.mockImplementation((...args: unknown[]) => {
|
|
464
|
+
const firstArg = args[0] as { model: string };
|
|
465
|
+
expect(firstArg.model).toBe('claude-sonnet-4-20250514');
|
|
466
|
+
return createMockStream(['Default model response.']);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
const { orchestrator } = setupOrchestrator();
|
|
470
|
+
await orchestrator.handleCallerUtterance('Hello');
|
|
471
|
+
orchestrator.destroy();
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test('uses calls.model override from config when set', async () => {
|
|
475
|
+
mockCallModel = 'claude-haiku-4-5-20251001';
|
|
476
|
+
mockStreamFn.mockImplementation((...args: unknown[]) => {
|
|
477
|
+
const firstArg = args[0] as { model: string };
|
|
478
|
+
expect(firstArg.model).toBe('claude-haiku-4-5-20251001');
|
|
479
|
+
return createMockStream(['Override model response.']);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
const { orchestrator } = setupOrchestrator();
|
|
483
|
+
await orchestrator.handleCallerUtterance('Hello');
|
|
484
|
+
orchestrator.destroy();
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
test('treats empty string calls.model as unset and falls back to default', async () => {
|
|
488
|
+
mockCallModel = '';
|
|
489
|
+
mockStreamFn.mockImplementation((...args: unknown[]) => {
|
|
490
|
+
const firstArg = args[0] as { model: string };
|
|
491
|
+
expect(firstArg.model).toBe('claude-sonnet-4-20250514');
|
|
492
|
+
return createMockStream(['Fallback model response.']);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
const { orchestrator } = setupOrchestrator();
|
|
496
|
+
await orchestrator.handleCallerUtterance('Hello');
|
|
497
|
+
orchestrator.destroy();
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
test('treats whitespace-only calls.model as unset and falls back to default', async () => {
|
|
501
|
+
mockCallModel = ' ';
|
|
502
|
+
mockStreamFn.mockImplementation((...args: unknown[]) => {
|
|
503
|
+
const firstArg = args[0] as { model: string };
|
|
504
|
+
expect(firstArg.model).toBe('claude-sonnet-4-20250514');
|
|
505
|
+
return createMockStream(['Fallback model response.']);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
const { orchestrator } = setupOrchestrator();
|
|
509
|
+
await orchestrator.handleCallerUtterance('Hello');
|
|
510
|
+
orchestrator.destroy();
|
|
511
|
+
});
|
|
454
512
|
});
|
|
@@ -55,6 +55,7 @@ import { _setBackend } from '../security/secure-keys.js';
|
|
|
55
55
|
import { loadConfig, invalidateConfigCache } from '../config/loader.js';
|
|
56
56
|
import { AssistantConfigSchema } from '../config/schema.js';
|
|
57
57
|
import { DEFAULT_CONFIG } from '../config/defaults.js';
|
|
58
|
+
import { buildElevenLabsVoiceSpec, resolveVoiceQualityProfile } from '../calls/voice-quality.js';
|
|
58
59
|
|
|
59
60
|
// ---------------------------------------------------------------------------
|
|
60
61
|
// Helpers
|
|
@@ -655,6 +656,23 @@ describe('AssistantConfigSchema', () => {
|
|
|
655
656
|
safety: {
|
|
656
657
|
denyCategories: [],
|
|
657
658
|
},
|
|
659
|
+
voice: {
|
|
660
|
+
mode: 'twilio_standard',
|
|
661
|
+
language: 'en-US',
|
|
662
|
+
transcriptionProvider: 'Deepgram',
|
|
663
|
+
fallbackToStandardOnError: true,
|
|
664
|
+
elevenlabs: {
|
|
665
|
+
voiceId: '',
|
|
666
|
+
voiceModelId: 'turbo_v2_5',
|
|
667
|
+
stability: 0.5,
|
|
668
|
+
similarityBoost: 0.75,
|
|
669
|
+
style: 0.0,
|
|
670
|
+
useSpeakerBoost: true,
|
|
671
|
+
agentId: '',
|
|
672
|
+
apiBaseUrl: 'https://api.elevenlabs.io',
|
|
673
|
+
registerCallTimeoutMs: 5000,
|
|
674
|
+
},
|
|
675
|
+
},
|
|
658
676
|
});
|
|
659
677
|
});
|
|
660
678
|
|
|
@@ -745,6 +763,261 @@ describe('AssistantConfigSchema', () => {
|
|
|
745
763
|
});
|
|
746
764
|
expect(result.success).toBe(false);
|
|
747
765
|
});
|
|
766
|
+
|
|
767
|
+
// ── Calls voice config ──────────────────────────────────────────────
|
|
768
|
+
|
|
769
|
+
test('config without calls.voice parses correctly and produces defaults', () => {
|
|
770
|
+
const result = AssistantConfigSchema.parse({});
|
|
771
|
+
expect(result.calls.voice.mode).toBe('twilio_standard');
|
|
772
|
+
expect(result.calls.voice.language).toBe('en-US');
|
|
773
|
+
expect(result.calls.voice.transcriptionProvider).toBe('Deepgram');
|
|
774
|
+
expect(result.calls.voice.fallbackToStandardOnError).toBe(true);
|
|
775
|
+
expect(result.calls.voice.elevenlabs.voiceId).toBe('');
|
|
776
|
+
expect(result.calls.voice.elevenlabs.voiceModelId).toBe('turbo_v2_5');
|
|
777
|
+
expect(result.calls.voice.elevenlabs.stability).toBe(0.5);
|
|
778
|
+
expect(result.calls.voice.elevenlabs.similarityBoost).toBe(0.75);
|
|
779
|
+
expect(result.calls.voice.elevenlabs.style).toBe(0.0);
|
|
780
|
+
expect(result.calls.voice.elevenlabs.useSpeakerBoost).toBe(true);
|
|
781
|
+
expect(result.calls.voice.elevenlabs.agentId).toBe('');
|
|
782
|
+
expect(result.calls.voice.elevenlabs.apiBaseUrl).toBe('https://api.elevenlabs.io');
|
|
783
|
+
expect(result.calls.voice.elevenlabs.registerCallTimeoutMs).toBe(5000);
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
test('accepts valid calls.voice overrides', () => {
|
|
787
|
+
const result = AssistantConfigSchema.parse({
|
|
788
|
+
calls: {
|
|
789
|
+
voice: {
|
|
790
|
+
mode: 'twilio_elevenlabs_tts',
|
|
791
|
+
language: 'es-ES',
|
|
792
|
+
transcriptionProvider: 'Google',
|
|
793
|
+
fallbackToStandardOnError: false,
|
|
794
|
+
elevenlabs: {
|
|
795
|
+
voiceId: 'abc123',
|
|
796
|
+
stability: 0.8,
|
|
797
|
+
},
|
|
798
|
+
},
|
|
799
|
+
},
|
|
800
|
+
});
|
|
801
|
+
expect(result.calls.voice.mode).toBe('twilio_elevenlabs_tts');
|
|
802
|
+
expect(result.calls.voice.language).toBe('es-ES');
|
|
803
|
+
expect(result.calls.voice.transcriptionProvider).toBe('Google');
|
|
804
|
+
expect(result.calls.voice.fallbackToStandardOnError).toBe(false);
|
|
805
|
+
expect(result.calls.voice.elevenlabs.voiceId).toBe('abc123');
|
|
806
|
+
expect(result.calls.voice.elevenlabs.stability).toBe(0.8);
|
|
807
|
+
// Defaults preserved for unset fields
|
|
808
|
+
expect(result.calls.voice.elevenlabs.voiceModelId).toBe('turbo_v2_5');
|
|
809
|
+
expect(result.calls.voice.elevenlabs.similarityBoost).toBe(0.75);
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
test('rejects invalid calls.voice.mode', () => {
|
|
813
|
+
const result = AssistantConfigSchema.safeParse({
|
|
814
|
+
calls: { voice: { mode: 'invalid_mode' } },
|
|
815
|
+
});
|
|
816
|
+
expect(result.success).toBe(false);
|
|
817
|
+
if (!result.success) {
|
|
818
|
+
const msgs = result.error.issues.map(i => i.message);
|
|
819
|
+
expect(msgs.some(m => m.includes('calls.voice.mode'))).toBe(true);
|
|
820
|
+
}
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
test('rejects invalid calls.voice.transcriptionProvider', () => {
|
|
824
|
+
const result = AssistantConfigSchema.safeParse({
|
|
825
|
+
calls: { voice: { transcriptionProvider: 'AWS' } },
|
|
826
|
+
});
|
|
827
|
+
expect(result.success).toBe(false);
|
|
828
|
+
if (!result.success) {
|
|
829
|
+
const msgs = result.error.issues.map(i => i.message);
|
|
830
|
+
expect(msgs.some(m => m.includes('calls.voice.transcriptionProvider'))).toBe(true);
|
|
831
|
+
}
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
test('rejects calls.voice.elevenlabs.stability out of range', () => {
|
|
835
|
+
const result = AssistantConfigSchema.safeParse({
|
|
836
|
+
calls: { voice: { elevenlabs: { stability: 1.5 } } },
|
|
837
|
+
});
|
|
838
|
+
expect(result.success).toBe(false);
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
test('rejects calls.voice.elevenlabs.registerCallTimeoutMs below 1000', () => {
|
|
842
|
+
const result = AssistantConfigSchema.safeParse({
|
|
843
|
+
calls: { voice: { elevenlabs: { registerCallTimeoutMs: 500 } } },
|
|
844
|
+
});
|
|
845
|
+
expect(result.success).toBe(false);
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
test('rejects calls.voice.elevenlabs.registerCallTimeoutMs above 15000', () => {
|
|
849
|
+
const result = AssistantConfigSchema.safeParse({
|
|
850
|
+
calls: { voice: { elevenlabs: { registerCallTimeoutMs: 20000 } } },
|
|
851
|
+
});
|
|
852
|
+
expect(result.success).toBe(false);
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
test('accepts optional calls.model', () => {
|
|
856
|
+
const result = AssistantConfigSchema.parse({
|
|
857
|
+
calls: { model: 'claude-haiku-4-5-20251001' },
|
|
858
|
+
});
|
|
859
|
+
expect(result.calls.model).toBe('claude-haiku-4-5-20251001');
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
test('calls.model is undefined by default', () => {
|
|
863
|
+
const result = AssistantConfigSchema.parse({});
|
|
864
|
+
expect(result.calls.model).toBeUndefined();
|
|
865
|
+
});
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
// ---------------------------------------------------------------------------
|
|
869
|
+
// Tests: Voice quality profile resolver
|
|
870
|
+
// ---------------------------------------------------------------------------
|
|
871
|
+
|
|
872
|
+
describe('resolveVoiceQualityProfile', () => {
|
|
873
|
+
test('returns correct profile for twilio_standard', () => {
|
|
874
|
+
const config = AssistantConfigSchema.parse({});
|
|
875
|
+
const profile = resolveVoiceQualityProfile(config);
|
|
876
|
+
expect(profile.mode).toBe('twilio_standard');
|
|
877
|
+
expect(profile.ttsProvider).toBe('Google');
|
|
878
|
+
expect(profile.voice).toBe('Google.en-US-Journey-O');
|
|
879
|
+
expect(profile.transcriptionProvider).toBe('Deepgram');
|
|
880
|
+
expect(profile.fallbackToStandardOnError).toBe(true);
|
|
881
|
+
expect(profile.validationErrors).toEqual([]);
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
test('returns correct profile for twilio_elevenlabs_tts with valid voiceId', () => {
|
|
885
|
+
const config = AssistantConfigSchema.parse({
|
|
886
|
+
calls: {
|
|
887
|
+
voice: {
|
|
888
|
+
mode: 'twilio_elevenlabs_tts',
|
|
889
|
+
elevenlabs: { voiceId: 'test-voice-id' },
|
|
890
|
+
},
|
|
891
|
+
},
|
|
892
|
+
});
|
|
893
|
+
const profile = resolveVoiceQualityProfile(config);
|
|
894
|
+
expect(profile.mode).toBe('twilio_elevenlabs_tts');
|
|
895
|
+
expect(profile.ttsProvider).toBe('ElevenLabs');
|
|
896
|
+
expect(profile.voice).toBe('test-voice-id-turbo_v2_5-0.5_0.75_0');
|
|
897
|
+
expect(profile.validationErrors).toEqual([]);
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
test('falls back for twilio_elevenlabs_tts with empty voiceId and fallback enabled', () => {
|
|
901
|
+
const config = AssistantConfigSchema.parse({
|
|
902
|
+
calls: {
|
|
903
|
+
voice: {
|
|
904
|
+
mode: 'twilio_elevenlabs_tts',
|
|
905
|
+
fallbackToStandardOnError: true,
|
|
906
|
+
elevenlabs: { voiceId: '' },
|
|
907
|
+
},
|
|
908
|
+
},
|
|
909
|
+
});
|
|
910
|
+
const profile = resolveVoiceQualityProfile(config);
|
|
911
|
+
expect(profile.mode).toBe('twilio_standard');
|
|
912
|
+
expect(profile.ttsProvider).toBe('Google');
|
|
913
|
+
expect(profile.voice).toBe('Google.en-US-Journey-O');
|
|
914
|
+
expect(profile.validationErrors.length).toBe(1);
|
|
915
|
+
expect(profile.validationErrors[0]).toContain('falling back');
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
test('returns errors for twilio_elevenlabs_tts with empty voiceId and fallback disabled', () => {
|
|
919
|
+
const config = AssistantConfigSchema.parse({
|
|
920
|
+
calls: {
|
|
921
|
+
voice: {
|
|
922
|
+
mode: 'twilio_elevenlabs_tts',
|
|
923
|
+
fallbackToStandardOnError: false,
|
|
924
|
+
elevenlabs: { voiceId: '' },
|
|
925
|
+
},
|
|
926
|
+
},
|
|
927
|
+
});
|
|
928
|
+
const profile = resolveVoiceQualityProfile(config);
|
|
929
|
+
expect(profile.mode).toBe('twilio_elevenlabs_tts');
|
|
930
|
+
expect(profile.validationErrors.length).toBe(1);
|
|
931
|
+
expect(profile.validationErrors[0]).toContain('voiceId is required');
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
test('returns correct profile for elevenlabs_agent with valid agentId', () => {
|
|
935
|
+
const config = AssistantConfigSchema.parse({
|
|
936
|
+
calls: {
|
|
937
|
+
voice: {
|
|
938
|
+
mode: 'elevenlabs_agent',
|
|
939
|
+
elevenlabs: { agentId: 'agent-123', voiceId: 'v1' },
|
|
940
|
+
},
|
|
941
|
+
},
|
|
942
|
+
});
|
|
943
|
+
const profile = resolveVoiceQualityProfile(config);
|
|
944
|
+
expect(profile.mode).toBe('elevenlabs_agent');
|
|
945
|
+
expect(profile.ttsProvider).toBe('ElevenLabs');
|
|
946
|
+
expect(profile.voice).toBe('v1-turbo_v2_5-0.5_0.75_0');
|
|
947
|
+
expect(profile.agentId).toBe('agent-123');
|
|
948
|
+
expect(profile.validationErrors).toEqual([]);
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
test('falls back for elevenlabs_agent with empty agentId and fallback enabled', () => {
|
|
952
|
+
const config = AssistantConfigSchema.parse({
|
|
953
|
+
calls: {
|
|
954
|
+
voice: {
|
|
955
|
+
mode: 'elevenlabs_agent',
|
|
956
|
+
fallbackToStandardOnError: true,
|
|
957
|
+
elevenlabs: { agentId: '' },
|
|
958
|
+
},
|
|
959
|
+
},
|
|
960
|
+
});
|
|
961
|
+
const profile = resolveVoiceQualityProfile(config);
|
|
962
|
+
expect(profile.mode).toBe('twilio_standard');
|
|
963
|
+
expect(profile.validationErrors.length).toBe(1);
|
|
964
|
+
expect(profile.validationErrors[0]).toContain('agentId is empty');
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
test('returns errors for elevenlabs_agent with empty agentId and fallback disabled', () => {
|
|
968
|
+
const config = AssistantConfigSchema.parse({
|
|
969
|
+
calls: {
|
|
970
|
+
voice: {
|
|
971
|
+
mode: 'elevenlabs_agent',
|
|
972
|
+
fallbackToStandardOnError: false,
|
|
973
|
+
elevenlabs: { agentId: '' },
|
|
974
|
+
},
|
|
975
|
+
},
|
|
976
|
+
});
|
|
977
|
+
const profile = resolveVoiceQualityProfile(config);
|
|
978
|
+
expect(profile.mode).toBe('elevenlabs_agent');
|
|
979
|
+
expect(profile.validationErrors.length).toBe(1);
|
|
980
|
+
expect(profile.validationErrors[0]).toContain('agentId is required');
|
|
981
|
+
});
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
// ---------------------------------------------------------------------------
|
|
985
|
+
// Tests: buildElevenLabsVoiceSpec
|
|
986
|
+
// ---------------------------------------------------------------------------
|
|
987
|
+
|
|
988
|
+
describe('buildElevenLabsVoiceSpec', () => {
|
|
989
|
+
test('produces correct voice string with all params', () => {
|
|
990
|
+
const spec = buildElevenLabsVoiceSpec({
|
|
991
|
+
voiceId: 'abc123',
|
|
992
|
+
voiceModelId: 'turbo_v2_5',
|
|
993
|
+
stability: 0.5,
|
|
994
|
+
similarityBoost: 0.75,
|
|
995
|
+
style: 0.0,
|
|
996
|
+
});
|
|
997
|
+
expect(spec).toBe('abc123-turbo_v2_5-0.5_0.75_0');
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
test('returns empty string when voiceId is empty', () => {
|
|
1001
|
+
const spec = buildElevenLabsVoiceSpec({
|
|
1002
|
+
voiceId: '',
|
|
1003
|
+
voiceModelId: 'turbo_v2_5',
|
|
1004
|
+
stability: 0.5,
|
|
1005
|
+
similarityBoost: 0.75,
|
|
1006
|
+
style: 0.0,
|
|
1007
|
+
});
|
|
1008
|
+
expect(spec).toBe('');
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
test('formats custom parameters correctly', () => {
|
|
1012
|
+
const spec = buildElevenLabsVoiceSpec({
|
|
1013
|
+
voiceId: 'myVoice',
|
|
1014
|
+
voiceModelId: 'eleven_multilingual_v2',
|
|
1015
|
+
stability: 0.8,
|
|
1016
|
+
similarityBoost: 0.9,
|
|
1017
|
+
style: 0.3,
|
|
1018
|
+
});
|
|
1019
|
+
expect(spec).toBe('myVoice-eleven_multilingual_v2-0.8_0.9_0.3');
|
|
1020
|
+
});
|
|
748
1021
|
});
|
|
749
1022
|
|
|
750
1023
|
// ---------------------------------------------------------------------------
|
|
@@ -994,6 +1267,11 @@ describe('loadConfig with schema validation', () => {
|
|
|
994
1267
|
expect(config.calls.userConsultTimeoutSeconds).toBe(120);
|
|
995
1268
|
expect(config.calls.disclosure.enabled).toBe(true);
|
|
996
1269
|
expect(config.calls.safety.denyCategories).toEqual([]);
|
|
1270
|
+
expect(config.calls.voice.mode).toBe('twilio_standard');
|
|
1271
|
+
expect(config.calls.voice.language).toBe('en-US');
|
|
1272
|
+
expect(config.calls.voice.transcriptionProvider).toBe('Deepgram');
|
|
1273
|
+
expect(config.calls.voice.elevenlabs.voiceId).toBe('');
|
|
1274
|
+
expect(config.calls.model).toBeUndefined();
|
|
997
1275
|
});
|
|
998
1276
|
});
|
|
999
1277
|
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
mock.module('../util/logger.js', () => ({
|
|
4
|
+
getLogger: () =>
|
|
5
|
+
new Proxy({} as Record<string, unknown>, {
|
|
6
|
+
get: () => () => {},
|
|
7
|
+
}),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
ElevenLabsClient,
|
|
12
|
+
ElevenLabsError,
|
|
13
|
+
type ElevenLabsClientOptions,
|
|
14
|
+
type ElevenLabsRegisterCallRequest,
|
|
15
|
+
} from '../calls/elevenlabs-client.js';
|
|
16
|
+
|
|
17
|
+
// ── Helpers ────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
const DEFAULT_OPTIONS: ElevenLabsClientOptions = {
|
|
20
|
+
apiBaseUrl: 'https://api.elevenlabs.io',
|
|
21
|
+
apiKey: 'test-api-key-secret',
|
|
22
|
+
timeoutMs: 5000,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const DEFAULT_REQUEST: ElevenLabsRegisterCallRequest = {
|
|
26
|
+
agent_id: 'agent-123',
|
|
27
|
+
from_number: '+15551111111',
|
|
28
|
+
to_number: '+15552222222',
|
|
29
|
+
direction: 'outbound',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
let originalFetch: typeof globalThis.fetch;
|
|
33
|
+
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
originalFetch = globalThis.fetch;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
globalThis.fetch = originalFetch;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// ── Tests ──────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
describe('ElevenLabsClient', () => {
|
|
45
|
+
describe('registerCall', () => {
|
|
46
|
+
test('successful register-call returns TwiML', async () => {
|
|
47
|
+
const twimlResponse = '<?xml version="1.0"?><Response><Connect><Stream url="wss://el.io/stream"/></Connect></Response>';
|
|
48
|
+
|
|
49
|
+
globalThis.fetch = mock(async () =>
|
|
50
|
+
new Response(twimlResponse, { status: 200 }),
|
|
51
|
+
) as unknown as typeof globalThis.fetch;
|
|
52
|
+
|
|
53
|
+
const client = new ElevenLabsClient(DEFAULT_OPTIONS);
|
|
54
|
+
const result = await client.registerCall(DEFAULT_REQUEST);
|
|
55
|
+
|
|
56
|
+
expect(result.twiml).toBe(twimlResponse);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('passes xi-api-key header in request', async () => {
|
|
60
|
+
let capturedHeaders: Headers | null = null;
|
|
61
|
+
|
|
62
|
+
globalThis.fetch = mock(async (url: string | URL | Request, init?: RequestInit) => {
|
|
63
|
+
capturedHeaders = new Headers(init?.headers);
|
|
64
|
+
return new Response('<Response/>', { status: 200 });
|
|
65
|
+
}) as unknown as typeof globalThis.fetch;
|
|
66
|
+
|
|
67
|
+
const client = new ElevenLabsClient(DEFAULT_OPTIONS);
|
|
68
|
+
await client.registerCall(DEFAULT_REQUEST);
|
|
69
|
+
|
|
70
|
+
expect(capturedHeaders).not.toBeNull();
|
|
71
|
+
expect(capturedHeaders!.get('xi-api-key')).toBe('test-api-key-secret');
|
|
72
|
+
expect(capturedHeaders!.get('Content-Type')).toBe('application/json');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('sends correct URL and request body', async () => {
|
|
76
|
+
let capturedUrl = '';
|
|
77
|
+
let capturedBody = '';
|
|
78
|
+
|
|
79
|
+
globalThis.fetch = mock(async (url: string | URL | Request, init?: RequestInit) => {
|
|
80
|
+
capturedUrl = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
|
|
81
|
+
capturedBody = typeof init?.body === 'string' ? init.body : '';
|
|
82
|
+
return new Response('<Response/>', { status: 200 });
|
|
83
|
+
}) as unknown as typeof globalThis.fetch;
|
|
84
|
+
|
|
85
|
+
const client = new ElevenLabsClient(DEFAULT_OPTIONS);
|
|
86
|
+
await client.registerCall(DEFAULT_REQUEST);
|
|
87
|
+
|
|
88
|
+
expect(capturedUrl).toBe('https://api.elevenlabs.io/v1/convai/twilio/register-call');
|
|
89
|
+
const parsed = JSON.parse(capturedBody);
|
|
90
|
+
expect(parsed.agent_id).toBe('agent-123');
|
|
91
|
+
expect(parsed.from_number).toBe('+15551111111');
|
|
92
|
+
expect(parsed.to_number).toBe('+15552222222');
|
|
93
|
+
expect(parsed.direction).toBe('outbound');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('non-2xx response throws ELEVENLABS_HTTP_ERROR', async () => {
|
|
97
|
+
globalThis.fetch = mock(async () =>
|
|
98
|
+
new Response('Internal Server Error', { status: 500 }),
|
|
99
|
+
) as unknown as typeof globalThis.fetch;
|
|
100
|
+
|
|
101
|
+
const client = new ElevenLabsClient(DEFAULT_OPTIONS);
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
await client.registerCall(DEFAULT_REQUEST);
|
|
105
|
+
expect(true).toBe(false); // Should not reach here
|
|
106
|
+
} catch (err) {
|
|
107
|
+
expect(err).toBeInstanceOf(ElevenLabsError);
|
|
108
|
+
const elErr = err as ElevenLabsError;
|
|
109
|
+
expect(elErr.code).toBe('ELEVENLABS_HTTP_ERROR');
|
|
110
|
+
expect(elErr.statusCode).toBe(500);
|
|
111
|
+
expect(elErr.message).toContain('500');
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('empty response throws ELEVENLABS_INVALID_RESPONSE', async () => {
|
|
116
|
+
globalThis.fetch = mock(async () =>
|
|
117
|
+
new Response('', { status: 200 }),
|
|
118
|
+
) as unknown as typeof globalThis.fetch;
|
|
119
|
+
|
|
120
|
+
const client = new ElevenLabsClient(DEFAULT_OPTIONS);
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
await client.registerCall(DEFAULT_REQUEST);
|
|
124
|
+
expect(true).toBe(false); // Should not reach here
|
|
125
|
+
} catch (err) {
|
|
126
|
+
expect(err).toBeInstanceOf(ElevenLabsError);
|
|
127
|
+
const elErr = err as ElevenLabsError;
|
|
128
|
+
expect(elErr.code).toBe('ELEVENLABS_INVALID_RESPONSE');
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('whitespace-only response throws ELEVENLABS_INVALID_RESPONSE', async () => {
|
|
133
|
+
globalThis.fetch = mock(async () =>
|
|
134
|
+
new Response(' \n ', { status: 200 }),
|
|
135
|
+
) as unknown as typeof globalThis.fetch;
|
|
136
|
+
|
|
137
|
+
const client = new ElevenLabsClient(DEFAULT_OPTIONS);
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
await client.registerCall(DEFAULT_REQUEST);
|
|
141
|
+
expect(true).toBe(false); // Should not reach here
|
|
142
|
+
} catch (err) {
|
|
143
|
+
expect(err).toBeInstanceOf(ElevenLabsError);
|
|
144
|
+
const elErr = err as ElevenLabsError;
|
|
145
|
+
expect(elErr.code).toBe('ELEVENLABS_INVALID_RESPONSE');
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('timeout throws ELEVENLABS_TIMEOUT', async () => {
|
|
150
|
+
globalThis.fetch = mock(async (_url: string | URL | Request, init?: RequestInit) => {
|
|
151
|
+
// Wait longer than the timeout
|
|
152
|
+
return new Promise<Response>((resolve, reject) => {
|
|
153
|
+
const timer = setTimeout(() => resolve(new Response('<Response/>', { status: 200 })), 10000);
|
|
154
|
+
init?.signal?.addEventListener('abort', () => {
|
|
155
|
+
clearTimeout(timer);
|
|
156
|
+
reject(new DOMException('The operation was aborted.', 'AbortError'));
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
}) as unknown as typeof globalThis.fetch;
|
|
160
|
+
|
|
161
|
+
const client = new ElevenLabsClient({ ...DEFAULT_OPTIONS, timeoutMs: 50 });
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
await client.registerCall(DEFAULT_REQUEST);
|
|
165
|
+
expect(true).toBe(false); // Should not reach here
|
|
166
|
+
} catch (err) {
|
|
167
|
+
expect(err).toBeInstanceOf(ElevenLabsError);
|
|
168
|
+
const elErr = err as ElevenLabsError;
|
|
169
|
+
expect(elErr.code).toBe('ELEVENLABS_TIMEOUT');
|
|
170
|
+
expect(elErr.message).toContain('50ms');
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('network error throws ELEVENLABS_HTTP_ERROR', async () => {
|
|
175
|
+
globalThis.fetch = mock(async () => {
|
|
176
|
+
throw new TypeError('Failed to fetch');
|
|
177
|
+
}) as unknown as typeof globalThis.fetch;
|
|
178
|
+
|
|
179
|
+
const client = new ElevenLabsClient(DEFAULT_OPTIONS);
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
await client.registerCall(DEFAULT_REQUEST);
|
|
183
|
+
expect(true).toBe(false); // Should not reach here
|
|
184
|
+
} catch (err) {
|
|
185
|
+
expect(err).toBeInstanceOf(ElevenLabsError);
|
|
186
|
+
const elErr = err as ElevenLabsError;
|
|
187
|
+
expect(elErr.code).toBe('ELEVENLABS_HTTP_ERROR');
|
|
188
|
+
expect(elErr.message).toContain('Failed to fetch');
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('API key is not included in logged data', async () => {
|
|
193
|
+
// The ElevenLabsClient logs agent_id and direction, but should never log the API key.
|
|
194
|
+
// We verify this by checking the request structure, not the log output.
|
|
195
|
+
let capturedBody = '';
|
|
196
|
+
|
|
197
|
+
globalThis.fetch = mock(async (_url: string | URL | Request, init?: RequestInit) => {
|
|
198
|
+
capturedBody = typeof init?.body === 'string' ? init.body : '';
|
|
199
|
+
return new Response('<Response/>', { status: 200 });
|
|
200
|
+
}) as unknown as typeof globalThis.fetch;
|
|
201
|
+
|
|
202
|
+
const client = new ElevenLabsClient(DEFAULT_OPTIONS);
|
|
203
|
+
await client.registerCall(DEFAULT_REQUEST);
|
|
204
|
+
|
|
205
|
+
// The request body should not contain the API key
|
|
206
|
+
expect(capturedBody).not.toContain('test-api-key-secret');
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
});
|