diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..c7662f5 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,8 @@ +[pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = -v --tb=short +filterwarnings = + ignore::DeprecationWarning:vapi_python.* diff --git a/requirements_dev.txt b/requirements_dev.txt index 523688e..3b6541f 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -8,5 +8,5 @@ coverage==4.5.4 Sphinx==1.8.5 twine==1.14.0 Click==7.1.2 - - +pytest>=7.0.0 +pytest-cov>=4.0.0 diff --git a/setup.py b/setup.py index 1ed5194..c7b4d47 100644 --- a/setup.py +++ b/setup.py @@ -17,21 +17,23 @@ def read_requirements(file): requirements = read_requirements('requirements.txt') -test_requirements = read_requirements('requirements.txt') +test_requirements = ['pytest>=7.0.0', 'pytest-cov>=4.0.0'] setup( author="Vapi AI", author_email='team@vapi.ai', - python_requires='>=3.6', + python_requires='>=3.8', classifiers=[ - 'Development Status :: 2 - Pre-Alpha', + 'Development Status :: 3 - Alpha', 'Intended Audience :: Developers', 'License :: OSI Approved :: MIT License', 'Natural Language :: English', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', ], description="This package lets you start Vapi calls directly from Python.", entry_points={ @@ -43,12 +45,12 @@ def read_requirements(file): license="MIT license", long_description=readme + '\n\n' + history, include_package_data=True, - keywords='vapi_python', + keywords='vapi_python vapi voice ai assistant', name='vapi_python', packages=find_packages(include=['vapi_python', 'vapi_python.*']), test_suite='tests', tests_require=test_requirements, - url='https://github.com/jordan.cde/vapi_python', - version='0.1.9', + url='https://github.com/VapiAI/client-sdk-python', + version='0.2.0', zip_safe=False, ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..9ebd816 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for Vapi Python SDK.""" diff --git a/tests/test_types.py b/tests/test_types.py new file mode 100644 index 0000000..2cc5f03 --- /dev/null +++ b/tests/test_types.py @@ -0,0 +1,253 @@ +""" +Tests for Vapi Python SDK type definitions. + +This module tests the MessageRole, Message, and related type utilities +for OpenAI API specification compliance. +""" + +import pytest +import warnings +from vapi_python.types import ( + MessageRole, + Message, + MessageType, + OpenAIModel, + VALID_MESSAGE_ROLES, + DEVELOPER_ROLE_MODELS, + validate_role, + supports_developer_role, +) + + +class TestMessageRole: + """Tests for the MessageRole enum.""" + + def test_all_roles_defined(self): + """Verify all expected roles are defined.""" + expected_roles = {'system', 'user', 'assistant', 'developer', 'tool', 'function'} + actual_roles = {role.value for role in MessageRole} + assert actual_roles == expected_roles + + def test_role_values(self): + """Verify role string values.""" + assert MessageRole.SYSTEM.value == "system" + assert MessageRole.USER.value == "user" + assert MessageRole.ASSISTANT.value == "assistant" + assert MessageRole.DEVELOPER.value == "developer" + assert MessageRole.TOOL.value == "tool" + assert MessageRole.FUNCTION.value == "function" + + def test_developer_role_exists(self): + """Test that developer role is available (OpenAI spec compliance).""" + assert hasattr(MessageRole, 'DEVELOPER') + assert MessageRole.DEVELOPER.value == "developer" + + +class TestValidateRole: + """Tests for the validate_role function.""" + + def test_valid_roles(self): + """Test validation of all valid roles.""" + for role in ['system', 'user', 'assistant', 'developer', 'tool', 'function']: + result = validate_role(role) + assert result == role + + def test_case_insensitive(self): + """Test that role validation is case-insensitive.""" + assert validate_role('USER') == 'user' + assert validate_role('Developer') == 'developer' + assert validate_role('SYSTEM') == 'system' + + def test_invalid_role_raises(self): + """Test that invalid roles raise ValueError.""" + with pytest.raises(ValueError) as exc_info: + validate_role('invalid_role') + assert 'Invalid role' in str(exc_info.value) + + def test_function_role_deprecation_warning(self): + """Test that 'function' role emits deprecation warning.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + result = validate_role('function') + assert result == 'function' + assert len(w) == 1 + assert issubclass(w[0].category, DeprecationWarning) + assert 'deprecated' in str(w[0].message).lower() + assert 'tool' in str(w[0].message).lower() + + def test_developer_role_no_warning(self): + """Test that 'developer' role does not emit warnings.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + result = validate_role('developer') + assert result == 'developer' + # Filter out any unrelated warnings + deprecation_warnings = [ + warning for warning in w + if issubclass(warning.category, DeprecationWarning) + ] + assert len(deprecation_warnings) == 0 + + +class TestMessage: + """Tests for the Message class.""" + + def test_basic_message(self): + """Test creating a basic message.""" + msg = Message(role='user', content='Hello!') + assert msg.role == 'user' + assert msg.content == 'Hello!' + assert msg.name is None + assert msg.tool_call_id is None + + def test_message_with_enum_role(self): + """Test creating a message with MessageRole enum.""" + msg = Message(role=MessageRole.DEVELOPER, content='Be concise.') + assert msg.role == 'developer' + assert msg.content == 'Be concise.' + + def test_developer_role_message(self): + """Test creating a message with developer role.""" + msg = Message(role='developer', content='Follow these instructions.') + assert msg.role == 'developer' + assert msg.content == 'Follow these instructions.' + + def test_tool_message_requires_tool_call_id(self): + """Test that tool messages require tool_call_id.""" + with pytest.raises(ValueError) as exc_info: + Message(role='tool', content='result') + assert 'tool_call_id' in str(exc_info.value) + + def test_tool_message_with_tool_call_id(self): + """Test creating a valid tool message.""" + msg = Message( + role='tool', + content='{"result": "success"}', + tool_call_id='call_123' + ) + assert msg.role == 'tool' + assert msg.tool_call_id == 'call_123' + + def test_to_dict_basic(self): + """Test to_dict for a basic message.""" + msg = Message(role='user', content='Test') + result = msg.to_dict() + assert result == {'role': 'user', 'content': 'Test'} + + def test_to_dict_with_all_fields(self): + """Test to_dict with all optional fields.""" + msg = Message( + role='tool', + content='result', + name='get_weather', + tool_call_id='call_abc' + ) + result = msg.to_dict() + assert result == { + 'role': 'tool', + 'content': 'result', + 'name': 'get_weather', + 'tool_call_id': 'call_abc' + } + + def test_invalid_role_raises(self): + """Test that invalid roles raise ValueError.""" + with pytest.raises(ValueError): + Message(role='invalid', content='test') + + +class TestMessageType: + """Tests for the MessageType enum.""" + + def test_message_types_defined(self): + """Verify all expected message types are defined.""" + assert MessageType.ADD_MESSAGE.value == "add-message" + assert MessageType.SAY.value == "say" + assert MessageType.END_CALL.value == "end-call" + assert MessageType.TRANSFER_CALL.value == "transfer-call" + + +class TestOpenAIModel: + """Tests for the OpenAIModel enum.""" + + def test_gpt5_models_defined(self): + """Test that GPT-5 series models are defined.""" + assert OpenAIModel.GPT_5_2.value == "gpt-5.2" + assert OpenAIModel.GPT_5_2_CHAT.value == "gpt-5.2-chat" + assert OpenAIModel.GPT_5_2_CHAT_LATEST.value == "gpt-5.2-chat-latest" + assert OpenAIModel.GPT_5_2_CODEX.value == "gpt-5.2-codex" + + def test_o_series_models_defined(self): + """Test that o-series models are defined.""" + assert OpenAIModel.O1.value == "o1" + assert OpenAIModel.O1_MINI.value == "o1-mini" + assert OpenAIModel.O3.value == "o3" + assert OpenAIModel.O3_MINI.value == "o3-mini" + assert OpenAIModel.O4_MINI.value == "o4-mini" + + def test_legacy_models_defined(self): + """Test that legacy models are still available.""" + assert OpenAIModel.GPT_4.value == "gpt-4" + assert OpenAIModel.GPT_4O.value == "gpt-4o" + assert OpenAIModel.GPT_3_5_TURBO.value == "gpt-3.5-turbo" + + +class TestSupportsDeveloperRole: + """Tests for the supports_developer_role function.""" + + def test_gpt5_models_support_developer(self): + """Test that GPT-5 models support developer role.""" + assert supports_developer_role("gpt-5.2") is True + assert supports_developer_role("gpt-5.2-chat") is True + assert supports_developer_role("gpt-5.2-chat-latest") is True + assert supports_developer_role("gpt-5.2-codex") is True + + def test_o_series_support_developer(self): + """Test that o-series models support developer role.""" + assert supports_developer_role("o1") is True + assert supports_developer_role("o1-mini") is True + assert supports_developer_role("o3") is True + assert supports_developer_role("o3-mini") is True + assert supports_developer_role("o4-mini") is True + + def test_older_models_no_developer(self): + """Test that older models do not support developer role.""" + assert supports_developer_role("gpt-4") is False + assert supports_developer_role("gpt-4o") is False + assert supports_developer_role("gpt-3.5-turbo") is False + + def test_unknown_model(self): + """Test behavior with unknown model.""" + assert supports_developer_role("unknown-model") is False + + +class TestValidMessageRolesConstant: + """Tests for the VALID_MESSAGE_ROLES constant.""" + + def test_contains_all_roles(self): + """Test that constant contains all valid roles.""" + expected = {'system', 'user', 'assistant', 'developer', 'tool', 'function'} + assert VALID_MESSAGE_ROLES == expected + + def test_developer_in_valid_roles(self): + """Test that developer role is in valid roles.""" + assert 'developer' in VALID_MESSAGE_ROLES + + +class TestDeveloperRoleModelsConstant: + """Tests for the DEVELOPER_ROLE_MODELS constant.""" + + def test_contains_gpt5_models(self): + """Test that GPT-5 models are in the set.""" + assert "gpt-5.2" in DEVELOPER_ROLE_MODELS + assert "gpt-5.2-chat" in DEVELOPER_ROLE_MODELS + + def test_contains_o_series(self): + """Test that o-series models are in the set.""" + assert "o1" in DEVELOPER_ROLE_MODELS + assert "o3" in DEVELOPER_ROLE_MODELS + + def test_does_not_contain_older_models(self): + """Test that older models are not in the set.""" + assert "gpt-4" not in DEVELOPER_ROLE_MODELS + assert "gpt-3.5-turbo" not in DEVELOPER_ROLE_MODELS diff --git a/tests/test_vapi.py b/tests/test_vapi.py new file mode 100644 index 0000000..b84a4e2 --- /dev/null +++ b/tests/test_vapi.py @@ -0,0 +1,320 @@ +""" +Tests for Vapi Python SDK main module. + +This module tests the Vapi class and its methods for proper +message handling and OpenAI API specification compliance. +""" + +import pytest +import warnings +from unittest.mock import Mock, patch, MagicMock +from vapi_python.vapi_python import Vapi, create_web_call +from vapi_python.types import MessageRole, MessageType + + +class TestVapiInit: + """Tests for Vapi initialization.""" + + def test_init_with_api_key(self): + """Test Vapi initialization with API key.""" + vapi = Vapi(api_key='test-key') + assert vapi.api_key == 'test-key' + assert vapi.api_url == 'https://api.vapi.ai' + + def test_init_with_custom_url(self): + """Test Vapi initialization with custom API URL.""" + vapi = Vapi(api_key='test-key', api_url='https://custom.api.com') + assert vapi.api_url == 'https://custom.api.com' + + +class TestVapiAddMessage: + """Tests for Vapi.add_message method.""" + + def setup_method(self): + """Set up test fixtures.""" + self.vapi = Vapi(api_key='test-key') + # Mock the client + self.mock_client = Mock() + self.vapi._Vapi__client = self.mock_client + + def test_add_message_user_role(self): + """Test adding a user message.""" + self.vapi.add_message('user', 'Hello!') + + self.mock_client.send_app_message.assert_called_once() + call_args = self.mock_client.send_app_message.call_args[0][0] + assert call_args['type'] == 'add-message' + assert call_args['message']['role'] == 'user' + assert call_args['message']['content'] == 'Hello!' + + def test_add_message_developer_role(self): + """Test adding a developer message (OpenAI spec compliance).""" + self.vapi.add_message('developer', 'Be concise.') + + self.mock_client.send_app_message.assert_called_once() + call_args = self.mock_client.send_app_message.call_args[0][0] + assert call_args['type'] == 'add-message' + assert call_args['message']['role'] == 'developer' + assert call_args['message']['content'] == 'Be concise.' + + def test_add_message_with_enum(self): + """Test adding a message using MessageRole enum.""" + self.vapi.add_message(MessageRole.DEVELOPER, 'Instructions here.') + + call_args = self.mock_client.send_app_message.call_args[0][0] + assert call_args['message']['role'] == 'developer' + + def test_add_message_system_role(self): + """Test adding a system message.""" + self.vapi.add_message('system', 'You are a helpful assistant.') + + call_args = self.mock_client.send_app_message.call_args[0][0] + assert call_args['message']['role'] == 'system' + + def test_add_message_assistant_role(self): + """Test adding an assistant message.""" + self.vapi.add_message('assistant', 'How can I help?') + + call_args = self.mock_client.send_app_message.call_args[0][0] + assert call_args['message']['role'] == 'assistant' + + def test_add_message_tool_role_with_id(self): + """Test adding a tool message with tool_call_id.""" + self.vapi.add_message( + 'tool', + '{"result": "success"}', + tool_call_id='call_123' + ) + + call_args = self.mock_client.send_app_message.call_args[0][0] + assert call_args['message']['role'] == 'tool' + assert call_args['message']['tool_call_id'] == 'call_123' + + def test_add_message_tool_role_without_id_raises(self): + """Test that tool messages without tool_call_id raise error.""" + with pytest.raises(ValueError) as exc_info: + self.vapi.add_message('tool', 'result') + assert 'tool_call_id' in str(exc_info.value) + + def test_add_message_with_name(self): + """Test adding a message with name parameter.""" + self.vapi.add_message( + 'tool', + '{"temp": 72}', + name='get_weather', + tool_call_id='call_abc' + ) + + call_args = self.mock_client.send_app_message.call_args[0][0] + assert call_args['message']['name'] == 'get_weather' + + def test_add_message_function_role_deprecated(self): + """Test that function role emits deprecation warning.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + self.vapi.add_message('function', 'result') + # Find deprecation warnings + deprecation_warnings = [ + warning for warning in w + if issubclass(warning.category, DeprecationWarning) + ] + assert len(deprecation_warnings) >= 1 + + def test_add_message_invalid_role_raises(self): + """Test that invalid roles raise ValueError.""" + with pytest.raises(ValueError) as exc_info: + self.vapi.add_message('invalid_role', 'content') + assert 'Invalid role' in str(exc_info.value) + + def test_add_message_case_insensitive(self): + """Test that role validation is case-insensitive.""" + self.vapi.add_message('DEVELOPER', 'Test') + + call_args = self.mock_client.send_app_message.call_args[0][0] + assert call_args['message']['role'] == 'developer' + + def test_add_message_no_client_raises(self): + """Test that add_message raises when call not started.""" + vapi = Vapi(api_key='test-key') # No client set + with pytest.raises(Exception) as exc_info: + vapi.add_message('user', 'Hello') + assert 'Call not started' in str(exc_info.value) + + +class TestVapiSend: + """Tests for Vapi.send method.""" + + def setup_method(self): + """Set up test fixtures.""" + self.vapi = Vapi(api_key='test-key') + self.mock_client = Mock() + self.vapi._Vapi__client = self.mock_client + + def test_send_valid_message(self): + """Test sending a valid message.""" + message = {'type': 'add-message', 'message': {'role': 'user', 'content': 'Hi'}} + self.vapi.send(message) + self.mock_client.send_app_message.assert_called_once_with(message) + + def test_send_missing_type_raises(self): + """Test that messages without 'type' raise ValueError.""" + with pytest.raises(ValueError) as exc_info: + self.vapi.send({'message': 'test'}) + assert 'Invalid message format' in str(exc_info.value) + + def test_send_non_dict_raises(self): + """Test that non-dict messages raise ValueError.""" + with pytest.raises(ValueError): + self.vapi.send("string message") + + def test_send_no_client_raises(self): + """Test that send raises when call not started.""" + vapi = Vapi(api_key='test-key') + with pytest.raises(Exception) as exc_info: + vapi.send({'type': 'test'}) + assert 'Call not started' in str(exc_info.value) + + +class TestVapiSay: + """Tests for Vapi.say method.""" + + def setup_method(self): + """Set up test fixtures.""" + self.vapi = Vapi(api_key='test-key') + self.mock_client = Mock() + self.vapi._Vapi__client = self.mock_client + + def test_say_basic(self): + """Test basic say functionality.""" + self.vapi.say("Hello there!") + + call_args = self.mock_client.send_app_message.call_args[0][0] + assert call_args['type'] == 'say' + assert call_args['content'] == 'Hello there!' + assert 'endCallAfter' not in call_args + + def test_say_with_end_call(self): + """Test say with end_call_after flag.""" + self.vapi.say("Goodbye!", end_call_after=True) + + call_args = self.mock_client.send_app_message.call_args[0][0] + assert call_args['type'] == 'say' + assert call_args['endCallAfter'] is True + + +class TestVapiEndCall: + """Tests for Vapi.end_call method.""" + + def setup_method(self): + """Set up test fixtures.""" + self.vapi = Vapi(api_key='test-key') + self.mock_client = Mock() + self.vapi._Vapi__client = self.mock_client + + def test_end_call(self): + """Test end_call sends correct message type.""" + self.vapi.end_call() + + call_args = self.mock_client.send_app_message.call_args[0][0] + assert call_args['type'] == 'end-call' + + +class TestVapiStop: + """Tests for Vapi.stop method.""" + + def test_stop_clears_client(self): + """Test that stop clears the client.""" + vapi = Vapi(api_key='test-key') + mock_client = Mock() + vapi._Vapi__client = mock_client + + vapi.stop() + + mock_client.leave.assert_called_once() + assert vapi._Vapi__client is None + + def test_stop_when_no_client(self): + """Test that stop is safe when no client exists.""" + vapi = Vapi(api_key='test-key') + vapi.stop() # Should not raise + + +class TestCreateWebCall: + """Tests for the create_web_call function.""" + + @patch('vapi_python.vapi_python.requests.post') + def test_create_web_call_success(self, mock_post): + """Test successful web call creation.""" + mock_response = Mock() + mock_response.status_code = 201 + mock_response.json.return_value = { + 'id': 'call_123', + 'webCallUrl': 'https://daily.co/room123' + } + mock_post.return_value = mock_response + + call_id, url = create_web_call( + 'https://api.vapi.ai', + 'test-key', + {'assistantId': 'asst_123'} + ) + + assert call_id == 'call_123' + assert url == 'https://daily.co/room123' + + @patch('vapi_python.vapi_python.requests.post') + def test_create_web_call_failure(self, mock_post): + """Test web call creation failure.""" + mock_response = Mock() + mock_response.status_code = 400 + mock_response.json.return_value = {'message': 'Invalid assistant ID'} + mock_post.return_value = mock_response + + with pytest.raises(Exception) as exc_info: + create_web_call( + 'https://api.vapi.ai', + 'test-key', + {'assistantId': 'invalid'} + ) + assert 'Invalid assistant ID' in str(exc_info.value) + + +class TestVapiStart: + """Tests for Vapi.start method.""" + + @patch('vapi_python.vapi_python.create_web_call') + @patch('vapi_python.vapi_python.DailyCall') + def test_start_with_assistant_id(self, mock_daily_call, mock_create_web_call): + """Test starting call with assistant_id.""" + mock_create_web_call.return_value = ('call_123', 'https://daily.co/room') + mock_client = Mock() + mock_daily_call.return_value = mock_client + + vapi = Vapi(api_key='test-key') + vapi.start(assistant_id='asst_123') + + mock_create_web_call.assert_called_once() + call_args = mock_create_web_call.call_args[0][2] + assert call_args['assistantId'] == 'asst_123' + + @patch('vapi_python.vapi_python.create_web_call') + @patch('vapi_python.vapi_python.DailyCall') + def test_start_with_assistant_config(self, mock_daily_call, mock_create_web_call): + """Test starting call with inline assistant config.""" + mock_create_web_call.return_value = ('call_123', 'https://daily.co/room') + mock_client = Mock() + mock_daily_call.return_value = mock_client + + vapi = Vapi(api_key='test-key') + assistant_config = {'model': 'gpt-5.2', 'voice': 'jennifer'} + vapi.start(assistant=assistant_config) + + call_args = mock_create_web_call.call_args[0][2] + assert call_args['assistant'] == assistant_config + + def test_start_no_assistant_raises(self): + """Test that start without assistant raises error.""" + vapi = Vapi(api_key='test-key') + with pytest.raises(Exception) as exc_info: + vapi.start() + assert 'No assistant specified' in str(exc_info.value) diff --git a/vapi_python/__init__.py b/vapi_python/__init__.py index f44dcbe..7b9012f 100644 --- a/vapi_python/__init__.py +++ b/vapi_python/__init__.py @@ -1,7 +1,54 @@ -"""Top-level package for Vapi Python SDK.""" +""" +Vapi Python SDK + +A Python SDK for integrating Vapi AI voice assistants into your applications. + +This package provides a simple interface to start voice calls with Vapi +AI assistants, send messages, and manage call state. + +Basic Usage: + >>> from vapi_python import Vapi + >>> vapi = Vapi(api_key='your-public-key') + >>> vapi.start(assistant_id='your-assistant-id') + >>> vapi.stop() + +For GPT-5.x and o-series models, you can use the developer role: + >>> vapi.add_message('developer', 'Be concise in your responses.') + +Type Definitions: + - MessageRole: Enum of valid message roles + - Message: Class for creating structured messages + - OpenAIModel: Enum of supported OpenAI models +""" from .vapi_python import Vapi +from .types import ( + MessageRole, + Message, + MessageType, + OpenAIModel, + VALID_MESSAGE_ROLES, + DEVELOPER_ROLE_MODELS, + validate_role, + supports_developer_role, +) __author__ = """Vapi AI""" __email__ = 'team@vapi.ai' -__version__ = '0.1.0' +__version__ = '0.2.0' + +__all__ = [ + # Main class + 'Vapi', + # Type definitions + 'MessageRole', + 'Message', + 'MessageType', + 'OpenAIModel', + # Constants + 'VALID_MESSAGE_ROLES', + 'DEVELOPER_ROLE_MODELS', + # Utility functions + 'validate_role', + 'supports_developer_role', +] diff --git a/vapi_python/types.py b/vapi_python/types.py new file mode 100644 index 0000000..20ae060 --- /dev/null +++ b/vapi_python/types.py @@ -0,0 +1,266 @@ +""" +Vapi Python SDK Type Definitions + +This module defines the message types and roles used in Vapi conversations, +aligned with the latest OpenAI API specification. +""" + +from enum import Enum +from typing import Optional, Dict, Any, List, Union +import warnings + + +class MessageRole(str, Enum): + """ + Supported message roles for conversation messages. + + Aligned with OpenAI API specification (May 2025). + + Attributes: + SYSTEM: System-level instructions for the assistant. + USER: Messages from the user/human participant. + ASSISTANT: Messages from the AI assistant. + DEVELOPER: Developer-level instructions with elevated priority. + Required for GPT-5.x and o-series models. Takes precedence + over system messages when both are present. + TOOL: Results from tool/function calls. + FUNCTION: DEPRECATED. Results from function calls. + Use TOOL role instead for new implementations. + """ + SYSTEM = "system" + USER = "user" + ASSISTANT = "assistant" + DEVELOPER = "developer" + TOOL = "tool" + FUNCTION = "function" # Deprecated - use TOOL instead + + +# Valid roles for add_message method +VALID_MESSAGE_ROLES = frozenset({ + MessageRole.SYSTEM.value, + MessageRole.USER.value, + MessageRole.ASSISTANT.value, + MessageRole.DEVELOPER.value, + MessageRole.TOOL.value, + MessageRole.FUNCTION.value, +}) + + +def validate_role(role: str) -> str: + """ + Validate and normalize a message role. + + Args: + role: The role string to validate. + + Returns: + The normalized role string (lowercase). + + Raises: + ValueError: If the role is not a valid message role. + + Warns: + DeprecationWarning: If the 'function' role is used. + + Example: + >>> validate_role("user") + 'user' + >>> validate_role("DEVELOPER") + 'developer' + >>> validate_role("function") # Warns about deprecation + 'function' + """ + normalized_role = role.lower() + + if normalized_role not in VALID_MESSAGE_ROLES: + raise ValueError( + f"Invalid role '{role}'. Valid roles are: " + f"{', '.join(sorted(VALID_MESSAGE_ROLES))}" + ) + + if normalized_role == MessageRole.FUNCTION.value: + warnings.warn( + "The 'function' role is deprecated. Use 'tool' role instead. " + "The 'function' role may be removed in a future version.", + DeprecationWarning, + stacklevel=3 + ) + + return normalized_role + + +class MessageType(str, Enum): + """ + Supported message types for Vapi communication. + + These types are used in the 'type' field of messages sent + via the send() method. + """ + ADD_MESSAGE = "add-message" + SAY = "say" + END_CALL = "end-call" + TRANSFER_CALL = "transfer-call" + + +class Message: + """ + Represents a conversation message. + + This class provides a structured way to create messages + for Vapi conversations with proper type validation. + + Attributes: + role: The role of the message sender (see MessageRole). + content: The text content of the message. + name: Optional name for tool/function messages. + tool_call_id: Optional ID for tool response messages. + + Example: + >>> msg = Message(role="user", content="Hello!") + >>> msg.to_dict() + {'role': 'user', 'content': 'Hello!'} + + >>> msg = Message(role="developer", content="Be concise.") + >>> msg.to_dict() + {'role': 'developer', 'content': 'Be concise.'} + """ + + def __init__( + self, + role: Union[str, MessageRole], + content: str, + name: Optional[str] = None, + tool_call_id: Optional[str] = None, + ): + """ + Initialize a Message instance. + + Args: + role: The role of the message sender. Can be a string or + MessageRole enum value. Valid roles: 'system', 'user', + 'assistant', 'developer', 'tool', 'function'. + content: The text content of the message. + name: Optional name identifier for tool/function messages. + tool_call_id: Optional ID linking to a specific tool call. + Required when role is 'tool'. + + Raises: + ValueError: If role is invalid or required fields are missing. + """ + if isinstance(role, MessageRole): + role = role.value + + self.role = validate_role(role) + self.content = content + self.name = name + self.tool_call_id = tool_call_id + + # Validate tool messages have required fields + if self.role == MessageRole.TOOL.value and not tool_call_id: + raise ValueError( + "Messages with 'tool' role require 'tool_call_id' parameter." + ) + + def to_dict(self) -> Dict[str, Any]: + """ + Convert the message to a dictionary format. + + Returns: + Dictionary representation of the message suitable + for sending via the Vapi API. + """ + result: Dict[str, Any] = { + "role": self.role, + "content": self.content, + } + + if self.name is not None: + result["name"] = self.name + + if self.tool_call_id is not None: + result["tool_call_id"] = self.tool_call_id + + return result + + +# OpenAI Model Constants +# These are the latest models available as of May 2025 + +class OpenAIModel(str, Enum): + """ + OpenAI model identifiers. + + These constants represent available OpenAI models that can be + used with Vapi assistants. + + Note: + Models prefixed with 'gpt-5' and 'o' series models support + the 'developer' role for enhanced instruction following. + """ + # GPT-4 Series + GPT_4 = "gpt-4" + GPT_4_TURBO = "gpt-4-turbo" + GPT_4O = "gpt-4o" + GPT_4O_MINI = "gpt-4o-mini" + + # GPT-4.1 Series (2024-2025) + GPT_4_1 = "gpt-4.1" + GPT_4_1_MINI = "gpt-4.1-mini" + GPT_4_1_NANO = "gpt-4.1-nano" + + # GPT-5 Series (Latest - 2025) + GPT_5_2 = "gpt-5.2" + GPT_5_2_CHAT = "gpt-5.2-chat" + GPT_5_2_CHAT_LATEST = "gpt-5.2-chat-latest" + GPT_5_2_CODEX = "gpt-5.2-codex" + + # O-Series Reasoning Models + O1 = "o1" + O1_MINI = "o1-mini" + O1_PREVIEW = "o1-preview" + O3 = "o3" + O3_MINI = "o3-mini" + O4_MINI = "o4-mini" + + # Legacy Models (for backwards compatibility) + GPT_3_5_TURBO = "gpt-3.5-turbo" + + +# Models that support the developer role +DEVELOPER_ROLE_MODELS = frozenset({ + OpenAIModel.GPT_5_2.value, + OpenAIModel.GPT_5_2_CHAT.value, + OpenAIModel.GPT_5_2_CHAT_LATEST.value, + OpenAIModel.GPT_5_2_CODEX.value, + OpenAIModel.O1.value, + OpenAIModel.O1_MINI.value, + OpenAIModel.O1_PREVIEW.value, + OpenAIModel.O3.value, + OpenAIModel.O3_MINI.value, + OpenAIModel.O4_MINI.value, +}) + + +def supports_developer_role(model: str) -> bool: + """ + Check if a model supports the developer role. + + The developer role is supported by GPT-5.x and o-series models. + When using these models, the developer role provides elevated + instruction priority over the system role. + + Args: + model: The model identifier string. + + Returns: + True if the model supports the developer role. + + Example: + >>> supports_developer_role("gpt-5.2") + True + >>> supports_developer_role("o3-mini") + True + >>> supports_developer_role("gpt-4o") + False + """ + return model in DEVELOPER_ROLE_MODELS diff --git a/vapi_python/vapi_python.py b/vapi_python/vapi_python.py index 69e9797..a52a169 100644 --- a/vapi_python/vapi_python.py +++ b/vapi_python/vapi_python.py @@ -1,12 +1,49 @@ +""" +Vapi Python SDK - Main Module + +This module provides the Vapi class for initiating and managing +voice calls with Vapi AI assistants. + +Example: + >>> from vapi_python import Vapi + >>> vapi = Vapi(api_key='your-public-key') + >>> vapi.start(assistant_id='your-assistant-id') + >>> # ... interact with the assistant ... + >>> vapi.stop() +""" + from daily import * import requests +from typing import Optional, Dict, Any, Union from .daily_call import DailyCall +from .types import ( + MessageRole, + Message, + MessageType, + validate_role, + VALID_MESSAGE_ROLES, + supports_developer_role, +) SAMPLE_RATE = 16000 CHANNELS = 1 -def create_web_call(api_url, api_key, payload): +def create_web_call(api_url: str, api_key: str, payload: Dict[str, Any]) -> tuple: + """ + Create a web call via the Vapi API. + + Args: + api_url: The base URL of the Vapi API. + api_key: The API key for authentication. + payload: The request payload containing call configuration. + + Returns: + A tuple of (call_id, web_call_url). + + Raises: + Exception: If the API call fails. + """ url = f"{api_url}/call/web" headers = { 'Authorization': 'Bearer ' + api_key, @@ -23,24 +60,105 @@ def create_web_call(api_url, api_key, payload): class Vapi: - def __init__(self, *, api_key, api_url="https://api.vapi.ai"): + """ + Vapi client for managing voice AI calls. + + This class provides methods to start, manage, and stop voice calls + with Vapi AI assistants. It supports both predefined assistants + (via assistant_id) and inline assistant configurations. + + Attributes: + api_key: The Vapi API key for authentication. + api_url: The base URL of the Vapi API. + + Example: + Basic usage with an assistant ID: + + >>> vapi = Vapi(api_key='your-public-key') + >>> vapi.start(assistant_id='your-assistant-id') + >>> vapi.stop() + + Using an inline assistant configuration: + + >>> vapi = Vapi(api_key='your-public-key') + >>> assistant = { + ... 'firstMessage': 'Hello! How can I help you today?', + ... 'model': 'gpt-5.2', + ... 'voice': 'jennifer-playht' + ... } + >>> vapi.start(assistant=assistant) + + Sending messages during a call: + + >>> vapi.add_message('user', 'What is the weather?') + >>> # For GPT-5.x models, you can use the developer role: + >>> vapi.add_message('developer', 'Respond briefly.') + """ + + def __init__(self, *, api_key: str, api_url: str = "https://api.vapi.ai"): + """ + Initialize the Vapi client. + + Args: + api_key: Your Vapi public API key. + api_url: The base URL for the Vapi API. + Defaults to 'https://api.vapi.ai'. + """ self.api_key = api_key self.api_url = api_url + self.__client: Optional[DailyCall] = None def start( self, *, - assistant_id=None, - assistant=None, - assistant_overrides=None, - squad_id=None, - squad=None, - ): + assistant_id: Optional[str] = None, + assistant: Optional[Dict[str, Any]] = None, + assistant_overrides: Optional[Dict[str, Any]] = None, + squad_id: Optional[str] = None, + squad: Optional[Dict[str, Any]] = None, + ) -> None: + """ + Start a new voice call with a Vapi assistant. + + You must provide exactly one of: assistant_id, assistant, + squad_id, or squad. + + Args: + assistant_id: The ID of a predefined assistant to use. + assistant: An inline assistant configuration dictionary. + See https://docs.vapi.ai/api-reference/assistants/create-assistant + for available options. + assistant_overrides: Optional dictionary of assistant + parameters to override. Can include 'variableValues' + for template variables. + squad_id: The ID of a predefined squad to use. + squad: An inline squad configuration dictionary. + + Raises: + Exception: If no assistant is specified or if the call + cannot be created. + + Example: + Using assistant_id with overrides: + + >>> vapi.start( + ... assistant_id='your-assistant-id', + ... assistant_overrides={ + ... 'variableValues': {'name': 'John'} + ... } + ... ) + """ # Start a new call if assistant_id: - payload = {'assistantId': assistant_id, 'assistantOverrides': assistant_overrides} + payload = { + 'assistantId': assistant_id, + 'assistantOverrides': assistant_overrides + } elif assistant: - payload = {'assistant': assistant, 'assistantOverrides': assistant_overrides} + payload = { + 'assistant': assistant, + 'assistantOverrides': assistant_overrides + } elif squad_id: payload = {'squadId': squad_id} elif squad: @@ -59,15 +177,39 @@ def start( self.__client = DailyCall() self.__client.join(web_call_url) - def stop(self): - self.__client.leave() - self.__client = None + def stop(self) -> None: + """ + Stop the current call and clean up resources. + + This method leaves the call and releases all associated + resources. After calling stop(), you can start a new call + with the start() method. + """ + if self.__client: + self.__client.leave() + self.__client = None - def send(self, message): + def send(self, message: Dict[str, Any]) -> None: """ Send a generic message to the assistant. - :param message: A dictionary containing the message type and content. + This is a low-level method for sending arbitrary messages. + For common operations, consider using the higher-level methods + like add_message() instead. + + Args: + message: A dictionary containing the message type and content. + Must include a 'type' key. + + Raises: + Exception: If the call has not been started. + ValueError: If the message format is invalid. + + Example: + >>> vapi.send({ + ... 'type': 'add-message', + ... 'message': {'role': 'user', 'content': 'Hello!'} + ... }) """ if not self.__client: raise Exception("Call not started. Please start the call first.") @@ -81,15 +223,137 @@ def send(self, message): except Exception as e: print(f"Failed to send message: {e}") - def add_message(self, role, content): + def add_message( + self, + role: Union[str, MessageRole], + content: str, + *, + name: Optional[str] = None, + tool_call_id: Optional[str] = None, + ) -> None: """ - method to send text messages with specific parameters. + Send a message to the assistant during the call. + + This method adds a message to the conversation. The role + determines how the message is interpreted by the model. + + Args: + role: The role of the message sender. Valid values: + - 'system': System-level instructions + - 'user': User messages + - 'assistant': Assistant responses (for context) + - 'developer': Developer instructions (GPT-5.x, o-series) + - 'tool': Tool/function call results + - 'function': DEPRECATED - use 'tool' instead + + content: The text content of the message. + + name: Optional name for tool/function messages. + + tool_call_id: Required for 'tool' role messages. + The ID of the tool call this message responds to. + + Raises: + Exception: If the call has not been started. + ValueError: If the role is invalid or required fields missing. + + Note: + The 'developer' role is supported by GPT-5.x and o-series + models. It provides elevated instruction priority over the + 'system' role. When using older models, use 'system' instead. + + Warning: + The 'function' role is deprecated. Use 'tool' role instead. + The 'function' role may be removed in a future version. + + Example: + Basic user message: + + >>> vapi.add_message('user', 'What is 2 + 2?') + + Developer instruction (GPT-5.x/o-series models): + + >>> vapi.add_message( + ... 'developer', + ... 'Respond in a formal tone.' + ... ) + + Tool response: + + >>> vapi.add_message( + ... 'tool', + ... '{"temperature": 72, "unit": "F"}', + ... tool_call_id='call_abc123' + ... ) """ + # Validate role (this also handles deprecation warning for 'function') + if isinstance(role, MessageRole): + validated_role = role.value + else: + validated_role = validate_role(role) + + # Build the message + message_content: Dict[str, Any] = { + 'role': validated_role, + 'content': content + } + + if name is not None: + message_content['name'] = name + + if tool_call_id is not None: + message_content['tool_call_id'] = tool_call_id + + # Validate tool messages have required fields + if validated_role == MessageRole.TOOL.value and not tool_call_id: + raise ValueError( + "Messages with 'tool' role require 'tool_call_id' parameter." + ) + message = { - 'type': 'add-message', - 'message': { - 'role': role, - 'content': content - } + 'type': MessageType.ADD_MESSAGE.value, + 'message': message_content + } + self.send(message) + + def say(self, text: str, *, end_call_after: bool = False) -> None: + """ + Make the assistant say a specific message. + + This method instructs the assistant to speak the provided + text immediately, interrupting any current speech. + + Args: + text: The text for the assistant to speak. + end_call_after: If True, the call will end after the + assistant finishes speaking. Defaults to False. + + Raises: + Exception: If the call has not been started. + + Example: + >>> vapi.say("Please hold while I look that up.") + >>> vapi.say("Goodbye!", end_call_after=True) + """ + message: Dict[str, Any] = { + 'type': MessageType.SAY.value, + 'content': text, } + if end_call_after: + message['endCallAfter'] = True + + self.send(message) + + def end_call(self) -> None: + """ + Request the assistant to end the call gracefully. + + This sends an end-call request to the assistant, which + will typically result in the assistant saying a farewell + message before ending the call. + + Raises: + Exception: If the call has not been started. + """ + message = {'type': MessageType.END_CALL.value} self.send(message)