diff --git a/README.md b/README.md index bb0f796..8b1db21 100644 --- a/README.md +++ b/README.md @@ -51,14 +51,14 @@ try: model="gpt-5", messages=[{"role": "user", "content": "Hello world"}], ) - print(chat.llm_response.choices[0].message.content) + print(chat.choices[0].message.content) # Or with the Responses API resp = client.responses.create( model="gpt-5", input="What are the main features of your premium plan?", ) - print(resp.llm_response.output_text) + print(resp.output_text) except GuardrailTripwireTriggered as e: print(f"Guardrail triggered: {e}") ``` diff --git a/docs/index.md b/docs/index.md index f4239e0..4640aaa 100644 --- a/docs/index.md +++ b/docs/index.md @@ -35,7 +35,7 @@ response = await client.responses.create( input="Hello" ) # Guardrails run automatically -print(response.llm_response.output_text) +print(response.output_text) ``` ## Next Steps diff --git a/docs/quickstart.md b/docs/quickstart.md index fe91f01..5c4695d 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -70,8 +70,8 @@ async def main(): input="Hello world" ) - # Access OpenAI response via .llm_response - print(response.llm_response.output_text) + # Access OpenAI response attributes directly + print(response.output_text) except GuardrailTripwireTriggered as exc: print(f"Guardrail triggered: {exc.guardrail_result.info}") @@ -79,7 +79,7 @@ async def main(): asyncio.run(main()) ``` -**That's it!** Your existing OpenAI code now includes automatic guardrail validation based on your pipeline configuration. Just use `response.llm_response` instead of `response`. +**That's it!** Your existing OpenAI code now includes automatic guardrail validation based on your pipeline configuration. The response object acts as a drop-in replacement for OpenAI responses with added guardrail results. ## Multi-Turn Conversations @@ -98,7 +98,7 @@ while True: model="gpt-4o" ) - response_content = response.llm_response.choices[0].message.content + response_content = response.choices[0].message.content print(f"Assistant: {response_content}") # ✅ Only append AFTER guardrails pass diff --git a/docs/ref/checks/hallucination_detection.md b/docs/ref/checks/hallucination_detection.md index ffc2043..0616902 100644 --- a/docs/ref/checks/hallucination_detection.md +++ b/docs/ref/checks/hallucination_detection.md @@ -76,7 +76,7 @@ response = await client.responses.create( ) # Guardrails automatically validate against your reference documents -print(response.llm_response.output_text) +print(response.output_text) ``` ### How It Works diff --git a/docs/tripwires.md b/docs/tripwires.md index 89cb6b2..5b261cd 100644 --- a/docs/tripwires.md +++ b/docs/tripwires.md @@ -25,7 +25,7 @@ try: model="gpt-5", input="Tell me a secret" ) - print(response.llm_response.output_text) + print(response.output_text) except GuardrailTripwireTriggered as exc: print(f"Guardrail triggered: {exc.guardrail_result.info}") diff --git a/examples/basic/azure_implementation.py b/examples/basic/azure_implementation.py index c475103..4279e25 100644 --- a/examples/basic/azure_implementation.py +++ b/examples/basic/azure_implementation.py @@ -75,7 +75,7 @@ async def process_input( ) # Extract the response content from the GuardrailsResponse - response_text = response.llm_response.choices[0].message.content + response_text = response.choices[0].message.content # Only show output if all guardrails pass print(f"\nAssistant: {response_text}") diff --git a/examples/basic/hello_world.py b/examples/basic/hello_world.py index da53e7f..3509753 100644 --- a/examples/basic/hello_world.py +++ b/examples/basic/hello_world.py @@ -48,14 +48,12 @@ async def process_input( model="gpt-4.1-mini", previous_response_id=response_id, ) - - console.print(f"\nAssistant output: {response.llm_response.output_text}", end="\n\n") - + console.print(f"\nAssistant output: {response.output_text}", end="\n\n") # Show guardrail results if any were run if response.guardrail_results.all_results: console.print(f"[dim]Guardrails checked: {len(response.guardrail_results.all_results)}[/dim]") - return response.llm_response.id + return response.id except GuardrailTripwireTriggered: raise diff --git a/examples/basic/local_model.py b/examples/basic/local_model.py index a3d5c2f..7aea228 100644 --- a/examples/basic/local_model.py +++ b/examples/basic/local_model.py @@ -48,7 +48,7 @@ async def process_input( ) # Access response content using standard OpenAI API - response_content = response.llm_response.choices[0].message.content + response_content = response.choices[0].message.content console.print(f"\nAssistant output: {response_content}", end="\n\n") # Add to conversation history diff --git a/examples/basic/multi_bundle.py b/examples/basic/multi_bundle.py index 4bdac20..a7cd4fe 100644 --- a/examples/basic/multi_bundle.py +++ b/examples/basic/multi_bundle.py @@ -66,15 +66,15 @@ async def process_input( with Live(output_text, console=console, refresh_per_second=10) as live: try: async for chunk in stream: - # Access streaming response exactly like native OpenAI API through .llm_response - if hasattr(chunk.llm_response, "delta") and chunk.llm_response.delta: - output_text += chunk.llm_response.delta + # Access streaming response exactly like native OpenAI API (flattened) + if hasattr(chunk, "delta") and chunk.delta: + output_text += chunk.delta live.update(output_text) # Get the response ID from the final chunk response_id_to_return = None - if hasattr(chunk.llm_response, "response") and hasattr(chunk.llm_response.response, "id"): - response_id_to_return = chunk.llm_response.response.id + if hasattr(chunk, "response") and hasattr(chunk.response, "id"): + response_id_to_return = chunk.response.id return response_id_to_return diff --git a/examples/basic/multiturn_chat_with_alignment.py b/examples/basic/multiturn_chat_with_alignment.py index 4ff9af2..581bb59 100644 --- a/examples/basic/multiturn_chat_with_alignment.py +++ b/examples/basic/multiturn_chat_with_alignment.py @@ -235,7 +235,7 @@ async def main(malicious: bool = False) -> None: tools=tools, ) print_guardrail_results("initial", resp) - choice = resp.llm_response.choices[0] + choice = resp.choices[0] message = choice.message tool_calls = getattr(message, "tool_calls", []) or [] @@ -327,7 +327,7 @@ async def main(malicious: bool = False) -> None: ) print_guardrail_results("final", resp) - final_message = resp.llm_response.choices[0].message + final_message = resp.choices[0].message console.print( Panel( final_message.content or "(no output)", diff --git a/examples/basic/pii_mask_example.py b/examples/basic/pii_mask_example.py index 5d4dd4b..abcf5dd 100644 --- a/examples/basic/pii_mask_example.py +++ b/examples/basic/pii_mask_example.py @@ -90,7 +90,7 @@ async def process_input( ) # Show the LLM response (already masked if PII was detected) - content = response.llm_response.choices[0].message.content + content = response.choices[0].message.content console.print(f"\n[bold blue]Assistant output:[/bold blue] {content}\n") # Show PII masking information if detected in pre-flight diff --git a/examples/basic/structured_outputs_example.py b/examples/basic/structured_outputs_example.py index 1d2414a..d86e87d 100644 --- a/examples/basic/structured_outputs_example.py +++ b/examples/basic/structured_outputs_example.py @@ -56,11 +56,11 @@ async def extract_user_info( ) # Access the parsed structured output - user_info = response.llm_response.output_parsed + user_info = response.output_parsed print(f"✅ Successfully extracted: {user_info.name}, {user_info.age}, {user_info.email}") # Return user info and response ID (only returned if guardrails pass) - return user_info, response.llm_response.id + return user_info, response.id except GuardrailTripwireTriggered: # Guardrail blocked - no response ID returned, conversation history unchanged diff --git a/examples/basic/suppress_tripwire.py b/examples/basic/suppress_tripwire.py index 19f9311..2ffb8d7 100644 --- a/examples/basic/suppress_tripwire.py +++ b/examples/basic/suppress_tripwire.py @@ -68,8 +68,8 @@ async def process_input( else: console.print("[bold green]No guardrails triggered.[/bold green]") - console.print(f"\n[bold blue]Assistant output:[/bold blue] {response.llm_response.output_text}\n") - return response.llm_response.id + console.print(f"\n[bold blue]Assistant output:[/bold blue] {response.output_text}\n") + return response.id except Exception as e: console.print(f"[bold red]Error: {e}[/bold red]") diff --git a/examples/hallucination_detection/run_hallucination_detection.py b/examples/hallucination_detection/run_hallucination_detection.py index f65ecb2..f901cf4 100644 --- a/examples/hallucination_detection/run_hallucination_detection.py +++ b/examples/hallucination_detection/run_hallucination_detection.py @@ -52,7 +52,7 @@ async def main(): model="gpt-4.1-mini", ) - response_content = response.llm_response.choices[0].message.content + response_content = response.choices[0].message.content console.print( Panel( f"[bold green]Tripwire not triggered[/bold green]\n\nResponse: {response_content}", diff --git a/examples/implementation_code/blocking/blocking_completions.py b/examples/implementation_code/blocking/blocking_completions.py index ef21fb1..7a57fd0 100644 --- a/examples/implementation_code/blocking/blocking_completions.py +++ b/examples/implementation_code/blocking/blocking_completions.py @@ -25,7 +25,7 @@ async def process_input( model="gpt-4.1-mini", ) - response_content = response.llm_response.choices[0].message.content + response_content = response.choices[0].message.content print(f"\nAssistant: {response_content}") # Guardrails passed - now safe to add to conversation history diff --git a/examples/implementation_code/blocking/blocking_responses.py b/examples/implementation_code/blocking/blocking_responses.py index 1209764..e442a66 100644 --- a/examples/implementation_code/blocking/blocking_responses.py +++ b/examples/implementation_code/blocking/blocking_responses.py @@ -18,9 +18,9 @@ async def process_input(guardrails_client: GuardrailsAsyncOpenAI, user_input: st # including pre-flight, input, and output stages, plus the LLM call response = await guardrails_client.responses.create(input=user_input, model="gpt-4.1-mini", previous_response_id=response_id) - print(f"\nAssistant: {response.llm_response.output_text}") + print(f"\nAssistant: {response.output_text}") - return response.llm_response.id + return response.id except GuardrailTripwireTriggered: # GuardrailsClient automatically handles tripwire exceptions diff --git a/examples/implementation_code/streaming/streaming_completions.py b/examples/implementation_code/streaming/streaming_completions.py index 2af0a09..6c62776 100644 --- a/examples/implementation_code/streaming/streaming_completions.py +++ b/examples/implementation_code/streaming/streaming_completions.py @@ -30,8 +30,8 @@ async def process_input( # Stream with output guardrail checks and accumulate response response_content = "" async for chunk in stream: - if chunk.llm_response.choices[0].delta.content: - delta = chunk.llm_response.choices[0].delta.content + if chunk.choices[0].delta.content: + delta = chunk.choices[0].delta.content print(delta, end="", flush=True) response_content += delta diff --git a/examples/implementation_code/streaming/streaming_responses.py b/examples/implementation_code/streaming/streaming_responses.py index e784906..3bfeb18 100644 --- a/examples/implementation_code/streaming/streaming_responses.py +++ b/examples/implementation_code/streaming/streaming_responses.py @@ -26,15 +26,15 @@ async def process_input(guardrails_client: GuardrailsAsyncOpenAI, user_input: st # Stream with output guardrail checks async for chunk in stream: - # Access streaming response exactly like native OpenAI API through .llm_response + # Access streaming response exactly like native OpenAI API # For responses API streaming, check for delta content - if hasattr(chunk.llm_response, "delta") and chunk.llm_response.delta: - print(chunk.llm_response.delta, end="", flush=True) + if hasattr(chunk, "delta") and chunk.delta: + print(chunk.delta, end="", flush=True) # Get the response ID from the final chunk response_id_to_return = None - if hasattr(chunk.llm_response, "response") and hasattr(chunk.llm_response.response, "id"): - response_id_to_return = chunk.llm_response.response.id + if hasattr(chunk, "response") and hasattr(chunk.response, "id"): + response_id_to_return = chunk.response.id return response_id_to_return diff --git a/examples/internal_examples/custom_context.py b/examples/internal_examples/custom_context.py index 511d327..c26e509 100644 --- a/examples/internal_examples/custom_context.py +++ b/examples/internal_examples/custom_context.py @@ -58,7 +58,7 @@ async def main() -> None: model="gpt-4.1-nano", messages=messages + [{"role": "user", "content": user_input}], ) - response_content = response.llm_response.choices[0].message.content + response_content = response.choices[0].message.content print("Assistant:", response_content) # Guardrails passed - now safe to add to conversation history diff --git a/src/guardrails/_base_client.py b/src/guardrails/_base_client.py index c4bb399..b15771b 100644 --- a/src/guardrails/_base_client.py +++ b/src/guardrails/_base_client.py @@ -7,9 +7,11 @@ from __future__ import annotations import logging +import warnings from dataclasses import dataclass from pathlib import Path from typing import Any, Final, Union +from weakref import WeakValueDictionary from openai.types import Completion from openai.types.chat import ChatCompletion, ChatCompletionChunk @@ -23,6 +25,28 @@ logger = logging.getLogger(__name__) +# Track instances that have emitted deprecation warnings +_warned_instance_ids: WeakValueDictionary[int, Any] = WeakValueDictionary() + + +def _warn_llm_response_deprecation(instance: Any) -> None: + """Emit deprecation warning for llm_response access. + + Args: + instance: The GuardrailsResponse instance. + """ + instance_id = id(instance) + if instance_id not in _warned_instance_ids: + warnings.warn( + "Accessing 'llm_response' is deprecated. " + "Access response attributes directly instead (e.g., use 'response.output_text' " + "instead of 'response.llm_response.output_text'). " + "The 'llm_response' attribute will be removed in future versions.", + DeprecationWarning, + stacklevel=3, + ) + _warned_instance_ids[instance_id] = instance + # Type alias for OpenAI response types OpenAIResponseType = Union[Completion, ChatCompletion, ChatCompletionChunk, Response] # noqa: UP007 @@ -54,22 +78,91 @@ def triggered_results(self) -> list[GuardrailResult]: return [r for r in self.all_results if r.tripwire_triggered] -@dataclass(frozen=True, slots=True) +@dataclass(frozen=True, slots=True, weakref_slot=True) class GuardrailsResponse: - """Wrapper around any OpenAI response with guardrail results. + """OpenAI response with guardrail results. - This class provides the same interface as OpenAI responses, with additional - guardrail results accessible via the guardrail_results attribute. + Access OpenAI response attributes directly: + response.output_text + response.choices[0].message.content - Users should access content the same way as with OpenAI responses: - - For chat completions: response.choices[0].message.content - - For responses: response.output_text - - For streaming: response.choices[0].delta.content + Access guardrail results: + response.guardrail_results.preflight + response.guardrail_results.input + response.guardrail_results.output """ - llm_response: OpenAIResponseType # OpenAI response object (chat completion, response, etc.) + _llm_response: OpenAIResponseType guardrail_results: GuardrailResults + def __init__( + self, + llm_response: OpenAIResponseType | None = None, + guardrail_results: GuardrailResults | None = None, + *, + _llm_response: OpenAIResponseType | None = None, + ) -> None: + """Initialize GuardrailsResponse. + + Args: + llm_response: OpenAI response object. + guardrail_results: Guardrail results. + _llm_response: OpenAI response object (keyword-only alias). + + Raises: + TypeError: If arguments are invalid. + """ + if llm_response is not None and _llm_response is not None: + msg = "Cannot specify both 'llm_response' and '_llm_response'" + raise TypeError(msg) + + if llm_response is None and _llm_response is None: + msg = "Must specify either 'llm_response' or '_llm_response'" + raise TypeError(msg) + + if guardrail_results is None: + msg = "Missing required argument: 'guardrail_results'" + raise TypeError(msg) + + response_obj = llm_response if llm_response is not None else _llm_response + + object.__setattr__(self, "_llm_response", response_obj) + object.__setattr__(self, "guardrail_results", guardrail_results) + + @property + def llm_response(self) -> OpenAIResponseType: + """Access underlying OpenAI response (deprecated). + + Returns: + OpenAI response object. + """ + _warn_llm_response_deprecation(self) + return self._llm_response + + def __getattr__(self, name: str) -> Any: + """Delegate attribute access to underlying OpenAI response. + + Args: + name: Attribute name. + + Returns: + Attribute value from OpenAI response. + + Raises: + AttributeError: If attribute doesn't exist. + """ + return getattr(self._llm_response, name) + + def __dir__(self) -> list[str]: + """List all available attributes including delegated ones. + + Returns: + Sorted list of attribute names. + """ + own_attrs = set(object.__dir__(self)) + delegated_attrs = set(dir(self._llm_response)) + return sorted(own_attrs | delegated_attrs) + class GuardrailsBaseClient: """Base class with shared functionality for guardrails clients.""" @@ -135,7 +228,7 @@ def _create_guardrails_response( output=output_results, ) return GuardrailsResponse( - llm_response=llm_response, + _llm_response=llm_response, guardrail_results=guardrail_results, ) diff --git a/tests/unit/test_response_flattening.py b/tests/unit/test_response_flattening.py new file mode 100644 index 0000000..9597043 --- /dev/null +++ b/tests/unit/test_response_flattening.py @@ -0,0 +1,415 @@ +"""Tests for GuardrailsResponse attribute delegation and deprecation warnings.""" + +from __future__ import annotations + +import warnings +from types import SimpleNamespace +from typing import Any + +import pytest + +from guardrails._base_client import GuardrailResults, GuardrailsResponse +from guardrails.types import GuardrailResult + + +def _create_mock_chat_completion() -> Any: + """Create a mock ChatCompletion response.""" + return SimpleNamespace( + id="chatcmpl-123", + choices=[ + SimpleNamespace( + index=0, + message=SimpleNamespace(content="Hello, world!", role="assistant"), + finish_reason="stop", + ) + ], + model="gpt-4", + usage=SimpleNamespace(prompt_tokens=10, completion_tokens=5, total_tokens=15), + ) + + +def _create_mock_response() -> Any: + """Create a mock Response (Responses API) response.""" + return SimpleNamespace( + id="resp-123", + output_text="Hello from responses API!", + conversation=SimpleNamespace(id="conv-123"), + ) + + +def _create_mock_guardrail_results() -> GuardrailResults: + """Create mock guardrail results.""" + return GuardrailResults( + preflight=[GuardrailResult(tripwire_triggered=False, info={"stage": "preflight"})], + input=[GuardrailResult(tripwire_triggered=False, info={"stage": "input"})], + output=[GuardrailResult(tripwire_triggered=False, info={"stage": "output"})], + ) + + +def test_direct_attribute_access_works() -> None: + """Test that attributes can be accessed directly without llm_response.""" + mock_llm_response = _create_mock_chat_completion() + guardrail_results = _create_mock_guardrail_results() + + response = GuardrailsResponse( + _llm_response=mock_llm_response, + guardrail_results=guardrail_results, + ) + + with warnings.catch_warnings(): + warnings.simplefilter("error") + assert response.id == "chatcmpl-123" # noqa: S101 + assert response.model == "gpt-4" # noqa: S101 + assert response.choices[0].message.content == "Hello, world!" # noqa: S101 + assert response.usage.total_tokens == 15 # noqa: S101 + + +def test_responses_api_direct_access_works() -> None: + """Test that Responses API attributes can be accessed directly.""" + mock_llm_response = _create_mock_response() + guardrail_results = _create_mock_guardrail_results() + + response = GuardrailsResponse( + _llm_response=mock_llm_response, + guardrail_results=guardrail_results, + ) + + with warnings.catch_warnings(): + warnings.simplefilter("error") + assert response.id == "resp-123" # noqa: S101 + assert response.output_text == "Hello from responses API!" # noqa: S101 + assert response.conversation.id == "conv-123" # noqa: S101 + + +def test_guardrail_results_access_no_warning() -> None: + """Test that accessing guardrail_results does NOT emit deprecation warning.""" + mock_llm_response = _create_mock_chat_completion() + guardrail_results = _create_mock_guardrail_results() + + response = GuardrailsResponse( + _llm_response=mock_llm_response, + guardrail_results=guardrail_results, + ) + + with warnings.catch_warnings(): + warnings.simplefilter("error") + assert response.guardrail_results is not None # noqa: S101 + assert len(response.guardrail_results.preflight) == 1 # noqa: S101 + assert len(response.guardrail_results.input) == 1 # noqa: S101 + assert len(response.guardrail_results.output) == 1 # noqa: S101 + + +def test_llm_response_access_emits_deprecation_warning() -> None: + """Test that accessing llm_response emits a deprecation warning.""" + mock_llm_response = _create_mock_chat_completion() + guardrail_results = _create_mock_guardrail_results() + + response = GuardrailsResponse( + _llm_response=mock_llm_response, + guardrail_results=guardrail_results, + ) + + with pytest.warns(DeprecationWarning, match="Accessing 'llm_response' is deprecated"): + _ = response.llm_response + + +def test_llm_response_chained_access_emits_warning() -> None: + """Test that accessing llm_response.attribute emits warning (only once).""" + mock_llm_response = _create_mock_chat_completion() + guardrail_results = _create_mock_guardrail_results() + + response = GuardrailsResponse( + _llm_response=mock_llm_response, + guardrail_results=guardrail_results, + ) + + with pytest.warns(DeprecationWarning, match="Accessing 'llm_response' is deprecated"): + _ = response.llm_response.id + + with warnings.catch_warnings(): + warnings.simplefilter("error") + _ = response.llm_response.model # Should not raise + + +def test_hasattr_works_correctly() -> None: + """Test that hasattr works correctly for delegated attributes.""" + mock_llm_response = _create_mock_chat_completion() + guardrail_results = _create_mock_guardrail_results() + + response = GuardrailsResponse( + _llm_response=mock_llm_response, + guardrail_results=guardrail_results, + ) + + with warnings.catch_warnings(): + warnings.simplefilter("error") + assert hasattr(response, "id") # noqa: S101 + assert hasattr(response, "choices") # noqa: S101 + assert hasattr(response, "model") # noqa: S101 + assert hasattr(response, "guardrail_results") # noqa: S101 + assert not hasattr(response, "nonexistent_attribute") # noqa: S101 + + +def test_getattr_works_correctly() -> None: + """Test that getattr works correctly for delegated attributes.""" + mock_llm_response = _create_mock_chat_completion() + guardrail_results = _create_mock_guardrail_results() + + response = GuardrailsResponse( + _llm_response=mock_llm_response, + guardrail_results=guardrail_results, + ) + + with warnings.catch_warnings(): + warnings.simplefilter("error") + assert response.id == "chatcmpl-123" # noqa: S101 + assert response.model == "gpt-4" # noqa: S101 + assert getattr(response, "nonexistent", "default") == "default" # noqa: S101 + + +def test_attribute_error_for_missing_attributes() -> None: + """Test that AttributeError is raised for missing attributes.""" + mock_llm_response = _create_mock_chat_completion() + guardrail_results = _create_mock_guardrail_results() + + response = GuardrailsResponse( + _llm_response=mock_llm_response, + guardrail_results=guardrail_results, + ) + + with pytest.raises(AttributeError): + _ = response.nonexistent_attribute + + +def test_method_calls_work() -> None: + """Test that method calls on delegated objects work correctly.""" + mock_llm_response = SimpleNamespace( + id="resp-123", + custom_method=lambda: "method result", + ) + guardrail_results = _create_mock_guardrail_results() + + response = GuardrailsResponse( + _llm_response=mock_llm_response, + guardrail_results=guardrail_results, + ) + + with warnings.catch_warnings(): + warnings.simplefilter("error") + assert response.custom_method() == "method result" # noqa: S101 + + +def test_nested_attribute_access_works() -> None: + """Test that nested attribute access works correctly.""" + mock_llm_response = _create_mock_chat_completion() + guardrail_results = _create_mock_guardrail_results() + + response = GuardrailsResponse( + _llm_response=mock_llm_response, + guardrail_results=guardrail_results, + ) + + # Nested access should work without warnings + with warnings.catch_warnings(): + warnings.simplefilter("error") + assert response.choices[0].message.content == "Hello, world!" # noqa: S101 + assert response.choices[0].message.role == "assistant" # noqa: S101 + assert response.choices[0].finish_reason == "stop" # noqa: S101 + + +def test_property_access_works() -> None: + """Test that property access on delegated objects works correctly.""" + # Create a mock with a property + class MockResponse: + @property + def computed_value(self) -> str: + return "computed" + + mock_llm_response = MockResponse() + guardrail_results = _create_mock_guardrail_results() + + response = GuardrailsResponse( + _llm_response=mock_llm_response, + guardrail_results=guardrail_results, + ) + + # Property access should work without warnings + with warnings.catch_warnings(): + warnings.simplefilter("error") + assert response.computed_value == "computed" # noqa: S101 + + +def test_backward_compatibility_still_works() -> None: + """Test that old pattern (response.llm_response.attr) still works despite warning.""" + mock_llm_response = _create_mock_chat_completion() + guardrail_results = _create_mock_guardrail_results() + + response = GuardrailsResponse( + _llm_response=mock_llm_response, + guardrail_results=guardrail_results, + ) + + # Old pattern should still work (with warning on first access) + with pytest.warns(DeprecationWarning): + assert response.llm_response.id == "chatcmpl-123" # noqa: S101 + + # Subsequent accesses should work without warnings + with warnings.catch_warnings(): + warnings.simplefilter("error") + assert response.llm_response.model == "gpt-4" # noqa: S101 + assert response.llm_response.choices[0].message.content == "Hello, world!" # noqa: S101 + + +def test_deprecation_warning_message_content() -> None: + """Test that the deprecation warning contains the expected message.""" + mock_llm_response = _create_mock_chat_completion() + guardrail_results = _create_mock_guardrail_results() + + response = GuardrailsResponse( + _llm_response=mock_llm_response, + guardrail_results=guardrail_results, + ) + + # Check the full warning message + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + _ = response.llm_response + + assert len(w) == 1 # noqa: S101 + assert issubclass(w[0].category, DeprecationWarning) # noqa: S101 + assert "Accessing 'llm_response' is deprecated" in str(w[0].message) # noqa: S101 + assert "response.output_text" in str(w[0].message) # noqa: S101 + assert "future versions" in str(w[0].message) # noqa: S101 + + +def test_warning_only_once_per_instance() -> None: + """Test that deprecation warning is only emitted once per instance.""" + mock_llm_response = _create_mock_chat_completion() + guardrail_results = _create_mock_guardrail_results() + + response = GuardrailsResponse( + _llm_response=mock_llm_response, + guardrail_results=guardrail_results, + ) + + # Track all warnings + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + + # Access llm_response multiple times (simulating streaming chunks) + _ = response.llm_response + _ = response.llm_response.id + _ = response.llm_response.model + _ = response.llm_response.choices + + # Should only have ONE warning despite multiple accesses + deprecation_warnings = [warning for warning in w if issubclass(warning.category, DeprecationWarning)] + assert len(deprecation_warnings) == 1 # noqa: S101 + + +def test_separate_instances_warn_independently() -> None: + """Test that different GuardrailsResponse instances warn independently.""" + mock_llm_response1 = _create_mock_chat_completion() + mock_llm_response2 = _create_mock_chat_completion() + guardrail_results = _create_mock_guardrail_results() + + response1 = GuardrailsResponse( + _llm_response=mock_llm_response1, + guardrail_results=guardrail_results, + ) + + response2 = GuardrailsResponse( + _llm_response=mock_llm_response2, + guardrail_results=guardrail_results, + ) + + # Track all warnings + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + + # Each instance should warn once + _ = response1.llm_response + _ = response2.llm_response + + # Multiple accesses to same instance should not warn again + _ = response1.llm_response + _ = response2.llm_response + + # Should have exactly TWO warnings (one per instance) + deprecation_warnings = [warning for warning in w if issubclass(warning.category, DeprecationWarning)] + assert len(deprecation_warnings) == 2 # noqa: S101 + + +def test_init_backward_compatibility_with_llm_response_param() -> None: + """Test that __init__ accepts both llm_response and _llm_response parameters.""" + mock_llm_response = _create_mock_chat_completion() + guardrail_results = _create_mock_guardrail_results() + + # Positional arguments (original order) should work + response_positional = GuardrailsResponse(mock_llm_response, guardrail_results) + assert response_positional.id == "chatcmpl-123" # noqa: S101 + assert response_positional.guardrail_results == guardrail_results # noqa: S101 + + # Old keyword parameter name should work (backward compatibility) + response_old = GuardrailsResponse( + llm_response=mock_llm_response, + guardrail_results=guardrail_results, + ) + assert response_old.id == "chatcmpl-123" # noqa: S101 + + # New keyword parameter name should work (keyword-only) + response_new = GuardrailsResponse( + _llm_response=mock_llm_response, + guardrail_results=guardrail_results, + ) + assert response_new.id == "chatcmpl-123" # noqa: S101 + + # Both llm_response parameters should raise TypeError + with pytest.raises(TypeError, match="Cannot specify both"): + GuardrailsResponse( + llm_response=mock_llm_response, + _llm_response=mock_llm_response, + guardrail_results=guardrail_results, + ) + + # Neither llm_response parameter should raise TypeError + with pytest.raises(TypeError, match="Must specify either"): + GuardrailsResponse(guardrail_results=guardrail_results) + + # Missing guardrail_results should raise TypeError + with pytest.raises(TypeError, match="Missing required argument"): + GuardrailsResponse(llm_response=mock_llm_response) + + +def test_dir_includes_delegated_attributes() -> None: + """Test that dir() includes attributes from the underlying llm_response.""" + mock_llm_response = _create_mock_chat_completion() + guardrail_results = _create_mock_guardrail_results() + + response = GuardrailsResponse( + _llm_response=mock_llm_response, + guardrail_results=guardrail_results, + ) + + # Get all attributes via dir() + attrs = dir(response) + + # Should include GuardrailsResponse's own attributes + assert "guardrail_results" in attrs # noqa: S101 + assert "llm_response" in attrs # noqa: S101 + assert "_llm_response" in attrs # noqa: S101 + + # Should include delegated attributes from llm_response + assert "id" in attrs # noqa: S101 + assert "model" in attrs # noqa: S101 + assert "choices" in attrs # noqa: S101 + + # Should be sorted + assert attrs == sorted(attrs) # noqa: S101 + + # Verify dir() on llm_response and response have overlap + llm_attrs = set(dir(mock_llm_response)) + response_attrs = set(attrs) + # All llm_response attributes should be in response's dir() + assert llm_attrs.issubset(response_attrs) # noqa: S101 +