diff --git a/src/pipecat/adapters/services/open_ai_adapter.py b/src/pipecat/adapters/services/open_ai_adapter.py index f544f2c29..37adcfc9a 100644 --- a/src/pipecat/adapters/services/open_ai_adapter.py +++ b/src/pipecat/adapters/services/open_ai_adapter.py @@ -52,7 +52,11 @@ class OpenAILLMAdapter(BaseLLMAdapter[OpenAILLMInvocationParams]): return "openai" def get_llm_invocation_params( - self, context: LLMContext, *, system_instruction: Optional[str] = None + self, + context: LLMContext, + *, + system_instruction: Optional[str] = None, + convert_developer_to_user: bool, ) -> OpenAILLMInvocationParams: """Get OpenAI-specific LLM invocation parameters from a universal LLM context. @@ -60,11 +64,16 @@ class OpenAILLMAdapter(BaseLLMAdapter[OpenAILLMInvocationParams]): context: The LLM context containing messages, tools, etc. system_instruction: Optional system instruction from service settings or ``run_inference``. If provided, prepended as a system message. + convert_developer_to_user: If True, convert "developer"-role messages + to "user"-role messages. Used by OpenAI-compatible services that + don't support the "developer" role. Returns: Dictionary of parameters for OpenAI's ChatCompletion API. """ - messages = self._from_universal_context_messages(self.get_messages(context)) + messages = self._from_universal_context_messages( + self.get_messages(context), convert_developer_to_user=convert_developer_to_user + ) if system_instruction: # Detect initial system message for warning purposes (don't extract) @@ -131,7 +140,10 @@ class OpenAILLMAdapter(BaseLLMAdapter[OpenAILLMInvocationParams]): return msgs def _from_universal_context_messages( - self, messages: List[LLMContextMessage] + self, + messages: List[LLMContextMessage], + *, + convert_developer_to_user: bool, ) -> List[ChatCompletionMessageParam]: result = [] for message in messages: @@ -141,6 +153,12 @@ class OpenAILLMAdapter(BaseLLMAdapter[OpenAILLMInvocationParams]): else: # Standard message, pass through unchanged result.append(message) + + if convert_developer_to_user: + for msg in result: + if msg.get("role") == "developer": + msg["role"] = "user" + return result def _from_standard_tool_choice( diff --git a/src/pipecat/adapters/services/perplexity_adapter.py b/src/pipecat/adapters/services/perplexity_adapter.py index c5d7f1d6c..18ebea648 100644 --- a/src/pipecat/adapters/services/perplexity_adapter.py +++ b/src/pipecat/adapters/services/perplexity_adapter.py @@ -50,7 +50,11 @@ class PerplexityLLMAdapter(OpenAILLMAdapter): """ def get_llm_invocation_params( - self, context: LLMContext, *, system_instruction: Optional[str] = None + self, + context: LLMContext, + *, + system_instruction: Optional[str] = None, + convert_developer_to_user: bool, ) -> OpenAILLMInvocationParams: """Get OpenAI-compatible invocation parameters with Perplexity message fixes applied. @@ -58,12 +62,18 @@ class PerplexityLLMAdapter(OpenAILLMAdapter): context: The LLM context containing messages, tools, etc. system_instruction: Optional system instruction from service settings or ``run_inference``. Forwarded to the parent adapter. + convert_developer_to_user: If True, convert "developer"-role messages + to "user"-role messages. Forwarded to the parent adapter. Returns: Dictionary of parameters for Perplexity's ChatCompletion API, with messages transformed to satisfy Perplexity's constraints. """ - params = super().get_llm_invocation_params(context, system_instruction=system_instruction) + params = super().get_llm_invocation_params( + context, + system_instruction=system_instruction, + convert_developer_to_user=convert_developer_to_user, + ) params["messages"] = self._transform_messages(list(params["messages"])) return params @@ -109,11 +119,8 @@ class PerplexityLLMAdapter(OpenAILLMAdapter): messages = copy.deepcopy(messages) - # Step 0: Convert "developer" messages to "user". - # Perplexity doesn't support the "developer" role. - for msg in messages: - if msg.get("role") == "developer": - msg["role"] = "user" + # Note: "developer" → "user" conversion is handled by the parent adapter + # via the convert_developer_to_user parameter. # Step 1: Convert non-initial system messages to "user". # Perplexity allows system messages at the start, but rejects them diff --git a/src/pipecat/services/cerebras/llm.py b/src/pipecat/services/cerebras/llm.py index 5be270ae1..5b883ecd3 100644 --- a/src/pipecat/services/cerebras/llm.py +++ b/src/pipecat/services/cerebras/llm.py @@ -30,6 +30,10 @@ class CerebrasLLMService(OpenAILLMService): maintaining full compatibility with OpenAI's interface and functionality. """ + # Cerebras doesn't support the "developer" message role. + # This value is used by BaseOpenAILLMService when calling the adapter. + supports_developer_role = False + Settings = CerebrasLLMSettings _settings: Settings diff --git a/src/pipecat/services/openai/base_llm.py b/src/pipecat/services/openai/base_llm.py index d2b19e2df..53de03d09 100644 --- a/src/pipecat/services/openai/base_llm.py +++ b/src/pipecat/services/openai/base_llm.py @@ -71,6 +71,15 @@ class BaseOpenAILLMService(LLMService): Settings = OpenAILLMSettings _settings: Settings + supports_developer_role: bool = True + """Whether this service's API supports the "developer" message role. + + OpenAI's native API supports it, but some OpenAI-compatible services + (e.g. Cerebras) do not. Subclasses that don't support it should set + this to ``False``, which causes the adapter to convert "developer" + messages to "user" messages before sending them to the API. + """ + class InputParams(BaseModel): """Input parameters for OpenAI model configuration. @@ -351,7 +360,9 @@ class BaseOpenAILLMService(LLMService): if isinstance(context, LLMContext): adapter = self.get_llm_adapter() invocation_params: OpenAILLMInvocationParams = adapter.get_llm_invocation_params( - context, system_instruction=effective_instruction + context, + system_instruction=effective_instruction, + convert_developer_to_user=not self.supports_developer_role, ) else: invocation_params = OpenAILLMInvocationParams( @@ -421,7 +432,9 @@ class BaseOpenAILLMService(LLMService): ) params: OpenAILLMInvocationParams = adapter.get_llm_invocation_params( - context, system_instruction=self._settings.system_instruction + context, + system_instruction=self._settings.system_instruction, + convert_developer_to_user=not self.supports_developer_role, ) chunks = await self.get_chat_completions(params) diff --git a/src/pipecat/services/perplexity/llm.py b/src/pipecat/services/perplexity/llm.py index b4aedc3f7..50953691a 100644 --- a/src/pipecat/services/perplexity/llm.py +++ b/src/pipecat/services/perplexity/llm.py @@ -41,6 +41,9 @@ class PerplexityLLMService(OpenAILLMService): """ adapter_class = PerplexityLLMAdapter + # Perplexity doesn't support the "developer" message role. + # This value is used by BaseOpenAILLMService when calling the adapter. + supports_developer_role = False Settings = PerplexityLLMSettings _settings: Settings diff --git a/tests/test_get_llm_invocation_params.py b/tests/test_get_llm_invocation_params.py index 6ad0af157..883c60631 100644 --- a/tests/test_get_llm_invocation_params.py +++ b/tests/test_get_llm_invocation_params.py @@ -15,7 +15,8 @@ For OpenAI adapter: 3. Complex message structures (like multi-part content) are preserved 4. System instructions are preserved throughout messages at any position 5. system_instruction is prepended as a system message, with conflict warnings -6. Developer messages pass through without triggering warnings +6. Developer messages pass through when convert_developer_to_user is False +7. Developer messages are converted to user when convert_developer_to_user is True For Gemini adapter: 1. LLMStandardMessage objects are converted to Gemini Content format @@ -85,6 +86,11 @@ from pipecat.processors.aggregators.llm_context import ( class TestOpenAIGetLLMInvocationParams(unittest.TestCase): + # In production, BaseOpenAILLMService always passes convert_developer_to_user + # to the adapter (True or False depending on the service's supports_developer_role). + # Tests below use False to simulate native OpenAI usage, except for the + # developer-conversion-specific tests which use True. + def setUp(self) -> None: """Sets up a common adapter instance for all tests.""" self.adapter = OpenAILLMAdapter() @@ -102,7 +108,7 @@ class TestOpenAIGetLLMInvocationParams(unittest.TestCase): context = LLMContext(messages=standard_messages) # Get invocation params - params = self.adapter.get_llm_invocation_params(context) + params = self.adapter.get_llm_invocation_params(context, convert_developer_to_user=False) # Verify messages are passed through unchanged self.assertEqual(params["messages"], standard_messages) @@ -134,7 +140,7 @@ class TestOpenAIGetLLMInvocationParams(unittest.TestCase): context = LLMContext(messages=messages) # Get invocation params - params = self.adapter.get_llm_invocation_params(context) + params = self.adapter.get_llm_invocation_params(context, convert_developer_to_user=False) # Should only include standard messages and OpenAI-specific ones # (3 total: system, standard user, openai assistant) @@ -181,7 +187,7 @@ class TestOpenAIGetLLMInvocationParams(unittest.TestCase): context = LLMContext(messages=messages) # Get invocation params - params = self.adapter.get_llm_invocation_params(context) + params = self.adapter.get_llm_invocation_params(context, convert_developer_to_user=False) # Verify complex content is preserved self.assertEqual(len(params["messages"]), 3) @@ -222,7 +228,7 @@ class TestOpenAIGetLLMInvocationParams(unittest.TestCase): context = LLMContext(messages=messages) # Get invocation params - params = self.adapter.get_llm_invocation_params(context) + params = self.adapter.get_llm_invocation_params(context, convert_developer_to_user=False) # OpenAI should preserve all messages unchanged, including multiple system messages self.assertEqual(len(params["messages"]), 7) @@ -249,7 +255,9 @@ class TestOpenAIGetLLMInvocationParams(unittest.TestCase): {"role": "user", "content": "Hello"}, ] context = LLMContext(messages=messages) - params = self.adapter.get_llm_invocation_params(context, system_instruction="Be helpful.") + params = self.adapter.get_llm_invocation_params( + context, system_instruction="Be helpful.", convert_developer_to_user=False + ) self.assertEqual(params["messages"][0]["role"], "system") self.assertEqual(params["messages"][0]["content"], "Be helpful.") @@ -262,7 +270,7 @@ class TestOpenAIGetLLMInvocationParams(unittest.TestCase): {"role": "user", "content": "Hello"}, ] context = LLMContext(messages=messages) - params = self.adapter.get_llm_invocation_params(context) + params = self.adapter.get_llm_invocation_params(context, convert_developer_to_user=False) self.assertEqual(len(params["messages"]), 2) self.assertEqual(params["messages"][0]["role"], "system") @@ -278,7 +286,7 @@ class TestOpenAIGetLLMInvocationParams(unittest.TestCase): with patch("pipecat.adapters.base_llm_adapter.logger") as mock_logger: params = self.adapter.get_llm_invocation_params( - context, system_instruction="Be concise." + context, system_instruction="Be concise.", convert_developer_to_user=False ) mock_logger.warning.assert_called_once() warning_msg = mock_logger.warning.call_args[0][0] @@ -298,7 +306,7 @@ class TestOpenAIGetLLMInvocationParams(unittest.TestCase): with patch("pipecat.adapters.base_llm_adapter.logger") as mock_logger: params = self.adapter.get_llm_invocation_params( - context, system_instruction="Be concise." + context, system_instruction="Be concise.", convert_developer_to_user=False ) mock_logger.warning.assert_not_called() @@ -315,10 +323,45 @@ class TestOpenAIGetLLMInvocationParams(unittest.TestCase): context = LLMContext(messages=messages) with patch("pipecat.adapters.base_llm_adapter.logger") as mock_logger: - self.adapter.get_llm_invocation_params(context, system_instruction="Be concise.") - self.adapter.get_llm_invocation_params(context, system_instruction="Be concise.") + self.adapter.get_llm_invocation_params( + context, system_instruction="Be concise.", convert_developer_to_user=False + ) + self.adapter.get_llm_invocation_params( + context, system_instruction="Be concise.", convert_developer_to_user=False + ) mock_logger.warning.assert_called_once() + def test_developer_messages_converted_to_user(self): + """Developer messages are converted to user role when convert_developer_to_user is True.""" + messages: list[LLMStandardMessage] = [ + {"role": "developer", "content": "Extra context."}, + {"role": "user", "content": "Hello"}, + ] + + context = LLMContext(messages=messages) + params = self.adapter.get_llm_invocation_params(context, convert_developer_to_user=True) + + self.assertEqual(params["messages"][0]["role"], "user") + self.assertEqual(params["messages"][0]["content"], "Extra context.") + + def test_developer_conversion_does_not_affect_other_roles(self): + """convert_developer_to_user only affects developer messages, not system/user/assistant.""" + messages: list[LLMStandardMessage] = [ + {"role": "system", "content": "System prompt."}, + {"role": "developer", "content": "Dev guidance."}, + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi"}, + ] + + context = LLMContext(messages=messages) + params = self.adapter.get_llm_invocation_params(context, convert_developer_to_user=True) + + self.assertEqual(params["messages"][0]["role"], "system") + self.assertEqual(params["messages"][1]["role"], "user") + self.assertEqual(params["messages"][1]["content"], "Dev guidance.") + self.assertEqual(params["messages"][2]["role"], "user") + self.assertEqual(params["messages"][3]["role"], "assistant") + class TestGeminiGetLLMInvocationParams(unittest.TestCase): def setUp(self) -> None: @@ -1377,6 +1420,10 @@ class TestAWSBedrockGetLLMInvocationParams(unittest.TestCase): class TestPerplexityGetLLMInvocationParams(unittest.TestCase): + # Perplexity doesn't support the "developer" role, so PerplexityLLMService + # sets supports_developer_role = False. Tests below pass + # convert_developer_to_user=True to match production behavior. + def setUp(self) -> None: """Sets up a common adapter instance for all tests.""" self.adapter = PerplexityLLMAdapter() @@ -1390,7 +1437,7 @@ class TestPerplexityGetLLMInvocationParams(unittest.TestCase): ] context = LLMContext(messages=messages) - params = self.adapter.get_llm_invocation_params(context) + params = self.adapter.get_llm_invocation_params(context, convert_developer_to_user=True) self.assertEqual(len(params["messages"]), 3) self.assertEqual(params["messages"][0]["role"], "user") @@ -1410,7 +1457,7 @@ class TestPerplexityGetLLMInvocationParams(unittest.TestCase): ] context = LLMContext(messages=messages) - params = self.adapter.get_llm_invocation_params(context) + params = self.adapter.get_llm_invocation_params(context, convert_developer_to_user=True) self.assertEqual(len(params["messages"]), 4) self.assertEqual(params["messages"][0]["role"], "system") @@ -1429,7 +1476,7 @@ class TestPerplexityGetLLMInvocationParams(unittest.TestCase): ] context = LLMContext(messages=messages) - params = self.adapter.get_llm_invocation_params(context) + params = self.adapter.get_llm_invocation_params(context, convert_developer_to_user=True) self.assertEqual(len(params["messages"]), 3) @@ -1457,7 +1504,7 @@ class TestPerplexityGetLLMInvocationParams(unittest.TestCase): ] context = LLMContext(messages=messages) - params = self.adapter.get_llm_invocation_params(context) + params = self.adapter.get_llm_invocation_params(context, convert_developer_to_user=True) # system(initial), user, assistant, merged(system→user + user) self.assertEqual(len(params["messages"]), 4) @@ -1482,7 +1529,7 @@ class TestPerplexityGetLLMInvocationParams(unittest.TestCase): ] context = LLMContext(messages=messages) - params = self.adapter.get_llm_invocation_params(context) + params = self.adapter.get_llm_invocation_params(context, convert_developer_to_user=True) self.assertEqual(len(params["messages"]), 3) self.assertEqual(params["messages"][0]["role"], "system") @@ -1500,7 +1547,7 @@ class TestPerplexityGetLLMInvocationParams(unittest.TestCase): ] context = LLMContext(messages=messages) - params = self.adapter.get_llm_invocation_params(context) + params = self.adapter.get_llm_invocation_params(context, convert_developer_to_user=True) self.assertEqual(len(params["messages"]), 1) self.assertEqual(params["messages"][0]["role"], "user") @@ -1519,7 +1566,7 @@ class TestPerplexityGetLLMInvocationParams(unittest.TestCase): ] context = LLMContext(messages=messages) - params = self.adapter.get_llm_invocation_params(context) + params = self.adapter.get_llm_invocation_params(context, convert_developer_to_user=True) self.assertEqual(len(params["messages"]), 1) self.assertEqual(params["messages"][0]["role"], "system") @@ -1538,7 +1585,7 @@ class TestPerplexityGetLLMInvocationParams(unittest.TestCase): ] context = LLMContext(messages=messages) - params = self.adapter.get_llm_invocation_params(context) + params = self.adapter.get_llm_invocation_params(context, convert_developer_to_user=True) # Trailing assistant removed → [system], system stays as-is self.assertEqual(len(params["messages"]), 1) @@ -1554,7 +1601,7 @@ class TestPerplexityGetLLMInvocationParams(unittest.TestCase): ] context = LLMContext(messages=messages) - params = self.adapter.get_llm_invocation_params(context) + params = self.adapter.get_llm_invocation_params(context, convert_developer_to_user=True) # After merging assistants we get [user, assistant(merged)], then trailing # assistant is removed, leaving just [user] @@ -1576,7 +1623,7 @@ class TestPerplexityGetLLMInvocationParams(unittest.TestCase): ] context = LLMContext(messages=messages) - params = self.adapter.get_llm_invocation_params(context) + params = self.adapter.get_llm_invocation_params(context, convert_developer_to_user=True) self.assertEqual(len(params["messages"]), 4) self.assertEqual(params["messages"][0]["role"], "user") @@ -1594,7 +1641,7 @@ class TestPerplexityGetLLMInvocationParams(unittest.TestCase): ] context = LLMContext(messages=messages) - params = self.adapter.get_llm_invocation_params(context) + params = self.adapter.get_llm_invocation_params(context, convert_developer_to_user=True) self.assertEqual(params["messages"][0]["role"], "user") self.assertEqual(params["messages"][0]["content"], "Extra context.") @@ -1609,7 +1656,7 @@ class TestPerplexityGetLLMInvocationParams(unittest.TestCase): ] context = LLMContext(messages=messages) - params = self.adapter.get_llm_invocation_params(context) + params = self.adapter.get_llm_invocation_params(context, convert_developer_to_user=True) # developer→user merged with following user self.assertEqual(len(params["messages"]), 3) @@ -1623,7 +1670,7 @@ class TestPerplexityGetLLMInvocationParams(unittest.TestCase): def test_empty_messages(self): """Test that empty messages list returns empty.""" context = LLMContext(messages=[]) - params = self.adapter.get_llm_invocation_params(context) + params = self.adapter.get_llm_invocation_params(context, convert_developer_to_user=True) self.assertEqual(params["messages"], []) diff --git a/tests/test_run_inference.py b/tests/test_run_inference.py index 884b1b922..bb1868973 100644 --- a/tests/test_run_inference.py +++ b/tests/test_run_inference.py @@ -60,8 +60,9 @@ async def test_openai_run_inference_with_llm_context(): # Verify assert result == "Hello! How can I help you today?" service.get_llm_adapter.assert_called_once() + # convert_developer_to_user=False because OpenAILLMService.supports_developer_role is True mock_adapter.get_llm_invocation_params.assert_called_once_with( - mock_context, system_instruction=None + mock_context, system_instruction=None, convert_developer_to_user=False ) service._client.chat.completions.create.assert_called_once_with( model="gpt-4", @@ -549,9 +550,12 @@ async def test_openai_run_inference_system_instruction_overrides_context(): ) assert result == "Response" - # Verify the adapter was called with the correct system_instruction + # Verify the adapter was called with the correct system_instruction. + # convert_developer_to_user=False because OpenAILLMService.supports_developer_role is True. mock_adapter.get_llm_invocation_params.assert_called_once_with( - mock_context, system_instruction="New system instruction" + mock_context, + system_instruction="New system instruction", + convert_developer_to_user=False, )