diff --git a/pydantic_ai_slim/pydantic_ai/models/anthropic.py b/pydantic_ai_slim/pydantic_ai/models/anthropic.py index 9bc04f7619..2796027508 100644 --- a/pydantic_ai_slim/pydantic_ai/models/anthropic.py +++ b/pydantic_ai_slim/pydantic_ai/models/anthropic.py @@ -885,7 +885,7 @@ async def _map_message( # noqa: C901 else: assert_never(m) if instructions := self._get_instructions(messages, model_request_parameters): - system_prompt_parts.insert(0, instructions) + system_prompt_parts.append(instructions) system_prompt = '\n\n'.join(system_prompt_parts) # Add cache_control to the last message content if anthropic_cache_messages is enabled diff --git a/pydantic_ai_slim/pydantic_ai/models/bedrock.py b/pydantic_ai_slim/pydantic_ai/models/bedrock.py index 5589b57d3e..d92ef6741e 100644 --- a/pydantic_ai_slim/pydantic_ai/models/bedrock.py +++ b/pydantic_ai_slim/pydantic_ai/models/bedrock.py @@ -663,7 +663,7 @@ async def _map_messages( # noqa: C901 last_message = cast(dict[str, Any], current_message) if instructions := self._get_instructions(messages, model_request_parameters): - system_prompt.insert(0, {'text': instructions}) + system_prompt.append({'text': instructions}) if system_prompt and settings.get('bedrock_cache_instructions') and profile.bedrock_supports_prompt_caching: system_prompt.append({'cachePoint': {'type': 'default'}}) diff --git a/pydantic_ai_slim/pydantic_ai/models/cohere.py b/pydantic_ai_slim/pydantic_ai/models/cohere.py index 7d5bfeaa11..95e2ebba29 100644 --- a/pydantic_ai_slim/pydantic_ai/models/cohere.py +++ b/pydantic_ai_slim/pydantic_ai/models/cohere.py @@ -272,7 +272,8 @@ def _map_messages( else: assert_never(message) if instructions := self._get_instructions(messages, model_request_parameters): - cohere_messages.insert(0, SystemChatMessageV2(role='system', content=instructions)) + system_prompt_count = sum(1 for m in cohere_messages if isinstance(m, SystemChatMessageV2)) + cohere_messages.insert(system_prompt_count, SystemChatMessageV2(role='system', content=instructions)) return cohere_messages def _get_tools(self, model_request_parameters: ModelRequestParameters) -> list[ToolV2]: diff --git a/pydantic_ai_slim/pydantic_ai/models/gemini.py b/pydantic_ai_slim/pydantic_ai/models/gemini.py index 1e71d16257..7ab6615a0e 100644 --- a/pydantic_ai_slim/pydantic_ai/models/gemini.py +++ b/pydantic_ai_slim/pydantic_ai/models/gemini.py @@ -365,7 +365,7 @@ async def _message_to_gemini_content( else: assert_never(m) if instructions := self._get_instructions(messages, model_request_parameters): - sys_prompt_parts.insert(0, _GeminiTextPart(text=instructions)) + sys_prompt_parts.append(_GeminiTextPart(text=instructions)) return sys_prompt_parts, contents async def _map_user_prompt(self, part: UserPromptPart) -> list[_GeminiPartUnion]: diff --git a/pydantic_ai_slim/pydantic_ai/models/google.py b/pydantic_ai_slim/pydantic_ai/models/google.py index c6f5459f08..2ae3fbbd4d 100644 --- a/pydantic_ai_slim/pydantic_ai/models/google.py +++ b/pydantic_ai_slim/pydantic_ai/models/google.py @@ -598,7 +598,7 @@ async def _map_messages( contents = [{'role': 'user', 'parts': [{'text': ''}]}] if instructions := self._get_instructions(messages, model_request_parameters): - system_parts.insert(0, {'text': instructions}) + system_parts.append({'text': instructions}) system_instruction = ContentDict(role='user', parts=system_parts) if system_parts else None return system_instruction, contents diff --git a/pydantic_ai_slim/pydantic_ai/models/groq.py b/pydantic_ai_slim/pydantic_ai/models/groq.py index d5f70fa451..422cdb40ce 100644 --- a/pydantic_ai_slim/pydantic_ai/models/groq.py +++ b/pydantic_ai_slim/pydantic_ai/models/groq.py @@ -431,7 +431,10 @@ def _map_messages( else: assert_never(message) if instructions := self._get_instructions(messages, model_request_parameters): - groq_messages.insert(0, chat.ChatCompletionSystemMessageParam(role='system', content=instructions)) + system_prompt_count = sum(1 for m in groq_messages if m.get('role') == 'system') + groq_messages.insert( + system_prompt_count, chat.ChatCompletionSystemMessageParam(role='system', content=instructions) + ) return groq_messages @staticmethod diff --git a/pydantic_ai_slim/pydantic_ai/models/huggingface.py b/pydantic_ai_slim/pydantic_ai/models/huggingface.py index ab6652dbb4..fc2c4859e6 100644 --- a/pydantic_ai_slim/pydantic_ai/models/huggingface.py +++ b/pydantic_ai_slim/pydantic_ai/models/huggingface.py @@ -368,7 +368,8 @@ async def _map_messages( else: assert_never(message) if instructions := self._get_instructions(messages, model_request_parameters): - hf_messages.insert(0, ChatCompletionInputMessage(content=instructions, role='system')) # type: ignore + system_prompt_count = sum(1 for m in hf_messages if getattr(m, 'role', None) == 'system') + hf_messages.insert(system_prompt_count, ChatCompletionInputMessage(content=instructions, role='system')) # type: ignore return hf_messages @staticmethod diff --git a/pydantic_ai_slim/pydantic_ai/models/mistral.py b/pydantic_ai_slim/pydantic_ai/models/mistral.py index 01fee32a25..afda4854ab 100644 --- a/pydantic_ai_slim/pydantic_ai/models/mistral.py +++ b/pydantic_ai_slim/pydantic_ai/models/mistral.py @@ -559,7 +559,8 @@ def _map_messages( else: assert_never(message) if instructions := self._get_instructions(messages, model_request_parameters): - mistral_messages.insert(0, MistralSystemMessage(content=instructions)) + system_prompt_count = sum(1 for m in mistral_messages if isinstance(m, MistralSystemMessage)) + mistral_messages.insert(system_prompt_count, MistralSystemMessage(content=instructions)) # Post-process messages to insert fake assistant message after tool message if followed by user message # to work around `Unexpected role 'user' after role 'tool'` error. diff --git a/pydantic_ai_slim/pydantic_ai/models/openai.py b/pydantic_ai_slim/pydantic_ai/models/openai.py index efe9629c3a..0d3ef00cce 100644 --- a/pydantic_ai_slim/pydantic_ai/models/openai.py +++ b/pydantic_ai_slim/pydantic_ai/models/openai.py @@ -890,7 +890,10 @@ async def _map_messages( else: assert_never(message) if instructions := self._get_instructions(messages, model_request_parameters): - openai_messages.insert(0, chat.ChatCompletionSystemMessageParam(content=instructions, role='system')) + system_prompt_count = sum(1 for m in openai_messages if m.get('role') == 'system') + openai_messages.insert( + system_prompt_count, chat.ChatCompletionSystemMessageParam(content=instructions, role='system') + ) return openai_messages @staticmethod @@ -1369,7 +1372,10 @@ async def _responses_create( # noqa: C901 # > Response input messages must contain the word 'json' in some form to use 'text.format' of type 'json_object'. # Apparently they're only checking input messages for "JSON", not instructions. assert isinstance(instructions, str) - openai_messages.insert(0, responses.EasyInputMessageParam(role='system', content=instructions)) + system_prompt_count = sum(1 for m in openai_messages if m.get('role') == 'system') + openai_messages.insert( + system_prompt_count, responses.EasyInputMessageParam(role='system', content=instructions) + ) instructions = OMIT if verbosity := model_settings.get('openai_text_verbosity'): diff --git a/tests/models/test_anthropic.py b/tests/models/test_anthropic.py index 0e1230a7a5..9c70ff4bc2 100644 --- a/tests/models/test_anthropic.py +++ b/tests/models/test_anthropic.py @@ -8065,3 +8065,33 @@ async def test_anthropic_container_id_from_stream_response(allow_model_requests: assert model_response.provider_details is not None assert model_response.provider_details.get('container_id') == 'container_from_stream' assert model_response.provider_details.get('finish_reason') == 'end_turn' + + +async def test_anthropic_system_prompts_and_instructions_ordering(): + """Test that instructions are appended after all system prompts in the system prompt string.""" + m = AnthropicModel('claude-sonnet-4-5', provider=AnthropicProvider(api_key='test-key')) + + messages: list[ModelRequest | ModelResponse] = [ + ModelRequest( + parts=[ + SystemPromptPart(content='System prompt 1'), + SystemPromptPart(content='System prompt 2'), + UserPromptPart(content='Hello'), + ], + instructions='Instructions content', + ), + ] + + system_prompt, anthropic_messages = await m._map_message(messages, ModelRequestParameters(), {}) # pyright: ignore[reportPrivateUsage] + + # Verify system prompts and instructions are joined in order: system1, system2, instructions + assert system_prompt == snapshot("""\ +System prompt 1 + +System prompt 2 + +Instructions content\ +""") + # Verify user message is in anthropic_messages + assert len(anthropic_messages) == 1 + assert anthropic_messages[0]['role'] == 'user' diff --git a/tests/models/test_google.py b/tests/models/test_google.py index be6d4bd68a..e87a51214f 100644 --- a/tests/models/test_google.py +++ b/tests/models/test_google.py @@ -29,6 +29,7 @@ FunctionToolCallEvent, FunctionToolResultEvent, ImageUrl, + ModelMessage, ModelRequest, ModelResponse, PartDeltaEvent, @@ -4610,3 +4611,30 @@ def get_country() -> str: ), ] ) + + +async def test_google_system_prompts_and_instructions_ordering(google_provider: GoogleProvider): + """Test that instructions are appended after all system prompts in the system instruction.""" + m = GoogleModel('gemini-2.0-flash', provider=google_provider) + + messages: list[ModelMessage] = [ + ModelRequest( + parts=[ + SystemPromptPart(content='System prompt 1'), + SystemPromptPart(content='System prompt 2'), + UserPromptPart(content='Hello'), + ], + instructions='Instructions content', + ), + ] + + system_instruction, contents = await m._map_messages(messages, ModelRequestParameters()) # pyright: ignore[reportPrivateUsage] + + # Verify system parts are in order: system1, system2, instructions + assert system_instruction == snapshot( + { + 'role': 'user', + 'parts': [{'text': 'System prompt 1'}, {'text': 'System prompt 2'}, {'text': 'Instructions content'}], + } + ) + assert contents == snapshot([{'role': 'user', 'parts': [{'text': 'Hello'}]}]) diff --git a/tests/models/test_openai.py b/tests/models/test_openai.py index 74cb3c1414..b49c8fd66f 100644 --- a/tests/models/test_openai.py +++ b/tests/models/test_openai.py @@ -3325,3 +3325,33 @@ async def test_openai_reasoning_in_thinking_tags(allow_model_requests: None): """, } ) + + +async def test_openai_chat_instructions_after_system_prompts(allow_model_requests: None): + """Test that instructions are inserted after all system prompts in mapped messages.""" + mock_client = MockOpenAI.create_mock(completion_message(ChatCompletionMessage(content='ok', role='assistant'))) + model = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client)) + + messages: list[ModelRequest | ModelResponse] = [ + ModelRequest( + parts=[ + SystemPromptPart(content='System prompt 1'), + SystemPromptPart(content='System prompt 2'), + UserPromptPart(content='Hello'), + ], + instructions='Instructions content', + ), + ] + + openai_messages = await model._map_messages(messages, ModelRequestParameters()) # pyright: ignore[reportPrivateUsage] + + # Verify order: system1, system2, instructions, user + assert len(openai_messages) == 4 + assert openai_messages == snapshot( + [ + {'role': 'system', 'content': 'System prompt 1'}, + {'role': 'system', 'content': 'System prompt 2'}, + {'content': 'Instructions content', 'role': 'system'}, + {'role': 'user', 'content': 'Hello'}, + ] + ) diff --git a/tests/models/test_openai_responses.py b/tests/models/test_openai_responses.py index b03e99bb91..af82923a09 100644 --- a/tests/models/test_openai_responses.py +++ b/tests/models/test_openai_responses.py @@ -24,6 +24,7 @@ PartEndEvent, PartStartEvent, RetryPromptPart, + SystemPromptPart, TextPart, TextPartDelta, ThinkingPart, @@ -8207,3 +8208,49 @@ async def test_web_search_call_action_find_in_page(allow_model_requests: None): 'type': 'web_search_call', } ) + + +async def test_openai_responses_system_prompts_ordering(allow_model_requests: None): + """Test that system prompts are correctly ordered in mapped messages.""" + c = response_message( + [ + ResponseOutputMessage( + id='msg_123', + content=cast(list[Content], [ResponseOutputText(text='ok', type='output_text', annotations=[])]), + role='assistant', + status='completed', + type='message', + ), + ], + ) + mock_client = MockOpenAIResponses.create_mock(c) + model = OpenAIResponsesModel('gpt-4o', provider=OpenAIProvider(openai_client=mock_client)) + + messages: list[ModelRequest | ModelResponse] = [ + ModelRequest( + parts=[ + SystemPromptPart(content='System prompt 1'), + SystemPromptPart(content='System prompt 2'), + UserPromptPart(content='Hello'), + ], + instructions='Instructions content', + ), + ] + + instructions, openai_messages = await model._map_messages( # type: ignore[reportPrivateUsage] + messages, + model_settings=cast(OpenAIResponsesModelSettings, {}), + model_request_parameters=ModelRequestParameters(), + ) + + # Verify instructions are returned separately + assert instructions == 'Instructions content' + + # Verify system prompts are in order, followed by user message + assert openai_messages == snapshot( + [ + {'role': 'system', 'content': 'System prompt 1'}, + {'role': 'system', 'content': 'System prompt 2'}, + {'role': 'user', 'content': 'Hello'}, + ] + )