From 4b3cf10e46e70d24013759d131c331cde257eeca Mon Sep 17 00:00:00 2001 From: Steven C Date: Tue, 25 Nov 2025 11:34:44 -0500 Subject: [PATCH 1/5] OpenAI object directly accessable --- README.md | 4 +- docs/index.md | 2 +- docs/quickstart.md | 8 +- docs/ref/checks/hallucination_detection.md | 2 +- docs/tripwires.md | 2 +- examples/basic/azure_implementation.py | 2 +- examples/basic/hello_world.py | 6 +- examples/basic/local_model.py | 2 +- examples/basic/multi_bundle.py | 10 +- .../basic/multiturn_chat_with_alignment.py | 4 +- examples/basic/pii_mask_example.py | 2 +- examples/basic/structured_outputs_example.py | 4 +- examples/basic/suppress_tripwire.py | 4 +- .../run_hallucination_detection.py | 2 +- .../blocking/blocking_completions.py | 2 +- .../blocking/blocking_responses.py | 4 +- .../streaming/streaming_completions.py | 4 +- .../streaming/streaming_responses.py | 10 +- examples/internal_examples/custom_context.py | 2 +- src/guardrails/_base_client.py | 81 +++- tests/unit/test_response_flattening.py | 357 ++++++++++++++++++ 21 files changed, 470 insertions(+), 44 deletions(-) create mode 100644 tests/unit/test_response_flattening.py 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..95f80ea 100644 --- a/examples/basic/hello_world.py +++ b/examples/basic/hello_world.py @@ -49,13 +49,13 @@ async def process_input( 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") + 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..96f6053 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,32 @@ logger = logging.getLogger(__name__) +# Track which GuardrailsResponse instances (by id) have already emitted deprecation warnings +# Uses WeakValueDictionary to avoid keeping instances alive just for warning tracking +_warned_instance_ids: WeakValueDictionary[int, Any] = WeakValueDictionary() + + +def _warn_llm_response_deprecation(instance: Any) -> None: + """Emit deprecation warning for llm_response access (once per instance). + + This function is called when users explicitly access the llm_response attribute. + Uses instance ID tracking to avoid warning multiple times for the same instance. + + Args: + instance: The GuardrailsResponse instance accessing llm_response. + """ + 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 +82,63 @@ 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. - This class provides the same interface as OpenAI responses, with additional - guardrail results accessible via the guardrail_results attribute. + This class acts as a transparent proxy to the underlying OpenAI response, + allowing direct access to all OpenAI response attributes while adding + guardrail results. - Users should access content the same way as with OpenAI responses: + Users can access response attributes directly (recommended): - For chat completions: response.choices[0].message.content - For responses: response.output_text - For streaming: response.choices[0].delta.content + + The guardrail results are accessible via: + - response.guardrail_results.preflight + - response.guardrail_results.input + - response.guardrail_results.output + + For backward compatibility, llm_response is still accessible but deprecated: + - response.llm_response (deprecated, emits warning once per instance) """ - llm_response: OpenAIResponseType # OpenAI response object (chat completion, response, etc.) + _llm_response: OpenAIResponseType # Private: OpenAI response object guardrail_results: GuardrailResults + @property + def llm_response(self) -> OpenAIResponseType: + """Access the underlying OpenAI response (deprecated). + + This property is provided for backward compatibility but is deprecated. + Users should access response attributes directly instead. + + Returns: + The underlying OpenAI response object. + """ + _warn_llm_response_deprecation(self) + return self._llm_response + + def __getattr__(self, name: str) -> Any: + """Delegate attribute access to _llm_response for transparency. + + This method is called when an attribute is not found on GuardrailsResponse. + It delegates the access to the underlying _llm_response object, making + GuardrailsResponse act as a transparent proxy. + + Args: + name: The attribute name being accessed. + + Returns: + The attribute value from _llm_response. + + Raises: + AttributeError: If the attribute doesn't exist on _llm_response either. + """ + # Access _llm_response directly without triggering deprecation warning + return getattr(self._llm_response, name) + class GuardrailsBaseClient: """Base class with shared functionality for guardrails clients.""" @@ -135,7 +204,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..922fa91 --- /dev/null +++ b/tests/unit/test_response_flattening.py @@ -0,0 +1,357 @@ +"""Tests for GuardrailsResponse flattening and deprecation warnings. + +This module tests that GuardrailsResponse acts as a transparent proxy to the +underlying OpenAI response, allowing direct attribute access while maintaining +backward compatibility with deprecation warnings for llm_response access. +""" + +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, + ) + + # Direct access should work without warnings + with warnings.catch_warnings(): + warnings.simplefilter("error") # Turn warnings into errors + 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, + ) + + # Direct access should work without warnings + 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, + ) + + # guardrail_results access should NOT emit warnings + with warnings.catch_warnings(): + warnings.simplefilter("error") # Turn warnings into errors + 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, + ) + + # Accessing llm_response should emit a deprecation warning + 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, + ) + + # Accessing llm_response.attribute should emit a deprecation warning (first time) + with pytest.warns(DeprecationWarning, match="Accessing 'llm_response' is deprecated"): + _ = response.llm_response.id + + # Accessing again should NOT emit another warning + with warnings.catch_warnings(): + warnings.simplefilter("error") # Turn warnings into errors + _ = 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, + ) + + # hasattr should work for delegated attributes + 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, + ) + + # getattr should work for delegated attributes + 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, + ) + + # Accessing non-existent attributes should raise AttributeError + with pytest.raises(AttributeError): + _ = response.nonexistent_attribute + + +def test_method_calls_work() -> None: + """Test that method calls on delegated objects work correctly.""" + # Create a mock with a method + 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, + ) + + # Method calls should work without warnings + 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") # Turn warnings into errors + 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 + From 6e79e258abf10041711cc071691af96c1f5efa8b Mon Sep 17 00:00:00 2001 From: Steven C Date: Tue, 25 Nov 2025 11:41:10 -0500 Subject: [PATCH 2/5] Delete commented out line --- examples/basic/hello_world.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/examples/basic/hello_world.py b/examples/basic/hello_world.py index 95f80ea..3509753 100644 --- a/examples/basic/hello_world.py +++ b/examples/basic/hello_world.py @@ -48,8 +48,6 @@ async def process_input( model="gpt-4.1-mini", previous_response_id=response_id, ) - - # console.print(f"\nAssistant output: {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: From e86555acfab63e03fcac9f5db4007cd0b2acebbe Mon Sep 17 00:00:00 2001 From: Steven C Date: Tue, 25 Nov 2025 12:04:29 -0500 Subject: [PATCH 3/5] Allowing introspection with dir --- src/guardrails/_base_client.py | 20 ++++++++++++++++ tests/unit/test_response_flattening.py | 33 ++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/src/guardrails/_base_client.py b/src/guardrails/_base_client.py index 96f6053..c314eb5 100644 --- a/src/guardrails/_base_client.py +++ b/src/guardrails/_base_client.py @@ -139,6 +139,26 @@ def __getattr__(self, name: str) -> Any: # Access _llm_response directly without triggering deprecation warning return getattr(self._llm_response, name) + def __dir__(self) -> list[str]: + """Support introspection by including delegated attributes. + + Returns a list of all attributes available on this object, including + both GuardrailsResponse's own attributes and all attributes from the + underlying _llm_response object. This enables proper IDE autocomplete + and interactive exploration. + + Returns: + List of all available attribute names. + """ + # Get GuardrailsResponse's own attributes + own_attrs = set(object.__dir__(self)) + + # Get attributes from the underlying _llm_response + delegated_attrs = set(dir(self._llm_response)) + + # Combine and return as sorted list + return sorted(own_attrs | delegated_attrs) + class GuardrailsBaseClient: """Base class with shared functionality for guardrails clients.""" diff --git a/tests/unit/test_response_flattening.py b/tests/unit/test_response_flattening.py index 922fa91..1de1816 100644 --- a/tests/unit/test_response_flattening.py +++ b/tests/unit/test_response_flattening.py @@ -355,3 +355,36 @@ def test_separate_instances_warn_independently() -> None: deprecation_warnings = [warning for warning in w if issubclass(warning.category, DeprecationWarning)] assert len(deprecation_warnings) == 2 # noqa: S101 + +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 + From b6f42acc10302033cdd90d1a9d25c68ee3d94aa9 Mon Sep 17 00:00:00 2001 From: Steven C Date: Tue, 25 Nov 2025 12:16:14 -0500 Subject: [PATCH 4/5] Backwards compatible init --- src/guardrails/_base_client.py | 35 ++++++++++++++++++++++++++ tests/unit/test_response_flattening.py | 32 +++++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/src/guardrails/_base_client.py b/src/guardrails/_base_client.py index c314eb5..2267319 100644 --- a/src/guardrails/_base_client.py +++ b/src/guardrails/_base_client.py @@ -107,6 +107,41 @@ class GuardrailsResponse: _llm_response: OpenAIResponseType # Private: OpenAI response object guardrail_results: GuardrailResults + def __init__( + self, + guardrail_results: GuardrailResults, + _llm_response: OpenAIResponseType | None = None, + llm_response: OpenAIResponseType | None = None, + ) -> None: + """Initialize GuardrailsResponse with backward-compatible parameter names. + + Accepts both _llm_response (new) and llm_response (deprecated) parameter names + to maintain backward compatibility with existing code. + + Args: + guardrail_results: The guardrail results. + _llm_response: The underlying OpenAI response (preferred parameter name). + llm_response: The underlying OpenAI response (deprecated parameter name). + + Raises: + TypeError: If neither or both llm_response parameters are provided. + """ + # Handle backward compatibility: accept both parameter names + 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) + + # Use whichever was provided + response_obj = _llm_response if _llm_response is not None else llm_response + + # Set fields on frozen dataclass using object.__setattr__ + object.__setattr__(self, "_llm_response", response_obj) + object.__setattr__(self, "guardrail_results", guardrail_results) + @property def llm_response(self) -> OpenAIResponseType: """Access the underlying OpenAI response (deprecated). diff --git a/tests/unit/test_response_flattening.py b/tests/unit/test_response_flattening.py index 1de1816..7efaf8d 100644 --- a/tests/unit/test_response_flattening.py +++ b/tests/unit/test_response_flattening.py @@ -356,6 +356,38 @@ def test_separate_instances_warn_independently() -> None: 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() + + # Old parameter name should still work (backward compatibility) + response_old = GuardrailsResponse( + llm_response=mock_llm_response, + guardrail_results=guardrail_results, + ) + assert response_old.id == "chatcmpl-123" # noqa: S101 + + # New parameter name should work + response_new = GuardrailsResponse( + _llm_response=mock_llm_response, + guardrail_results=guardrail_results, + ) + assert response_new.id == "chatcmpl-123" # noqa: S101 + + # Both 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 should raise TypeError + with pytest.raises(TypeError, match="Must specify either"): + GuardrailsResponse(guardrail_results=guardrail_results) + + def test_dir_includes_delegated_attributes() -> None: """Test that dir() includes attributes from the underlying llm_response.""" mock_llm_response = _create_mock_chat_completion() From 77a4ab0566ba89b30e6dc76e75f1493768ea04c9 Mon Sep 17 00:00:00 2001 From: Steven C Date: Tue, 25 Nov 2025 12:31:26 -0500 Subject: [PATCH 5/5] Correct constructor order --- src/guardrails/_base_client.py | 101 +++++++++---------------- tests/unit/test_response_flattening.py | 43 +++++------ 2 files changed, 53 insertions(+), 91 deletions(-) diff --git a/src/guardrails/_base_client.py b/src/guardrails/_base_client.py index 2267319..b15771b 100644 --- a/src/guardrails/_base_client.py +++ b/src/guardrails/_base_client.py @@ -25,19 +25,15 @@ logger = logging.getLogger(__name__) -# Track which GuardrailsResponse instances (by id) have already emitted deprecation warnings -# Uses WeakValueDictionary to avoid keeping instances alive just for warning tracking +# 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 (once per instance). - - This function is called when users explicitly access the llm_response attribute. - Uses instance ID tracking to avoid warning multiple times for the same instance. + """Emit deprecation warning for llm_response access. Args: - instance: The GuardrailsResponse instance accessing llm_response. + instance: The GuardrailsResponse instance. """ instance_id = id(instance) if instance_id not in _warned_instance_ids: @@ -84,114 +80,87 @@ def triggered_results(self) -> list[GuardrailResult]: @dataclass(frozen=True, slots=True, weakref_slot=True) class GuardrailsResponse: - """Wrapper around any OpenAI response with guardrail results. - - This class acts as a transparent proxy to the underlying OpenAI response, - allowing direct access to all OpenAI response attributes while adding - guardrail results. + """OpenAI response with guardrail results. - Users can access response attributes directly (recommended): - - For chat completions: response.choices[0].message.content - - For responses: response.output_text - - For streaming: response.choices[0].delta.content + Access OpenAI response attributes directly: + response.output_text + response.choices[0].message.content - The guardrail results are accessible via: - - response.guardrail_results.preflight - - response.guardrail_results.input - - response.guardrail_results.output - - For backward compatibility, llm_response is still accessible but deprecated: - - response.llm_response (deprecated, emits warning once per instance) + Access guardrail results: + response.guardrail_results.preflight + response.guardrail_results.input + response.guardrail_results.output """ - _llm_response: OpenAIResponseType # Private: OpenAI response object + _llm_response: OpenAIResponseType guardrail_results: GuardrailResults def __init__( self, - guardrail_results: GuardrailResults, - _llm_response: OpenAIResponseType | None = None, llm_response: OpenAIResponseType | None = None, + guardrail_results: GuardrailResults | None = None, + *, + _llm_response: OpenAIResponseType | None = None, ) -> None: - """Initialize GuardrailsResponse with backward-compatible parameter names. - - Accepts both _llm_response (new) and llm_response (deprecated) parameter names - to maintain backward compatibility with existing code. + """Initialize GuardrailsResponse. Args: - guardrail_results: The guardrail results. - _llm_response: The underlying OpenAI response (preferred parameter name). - llm_response: The underlying OpenAI response (deprecated parameter name). + llm_response: OpenAI response object. + guardrail_results: Guardrail results. + _llm_response: OpenAI response object (keyword-only alias). Raises: - TypeError: If neither or both llm_response parameters are provided. + TypeError: If arguments are invalid. """ - # Handle backward compatibility: accept both parameter names - if _llm_response is not None and llm_response is not None: + 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: + if llm_response is None and _llm_response is None: msg = "Must specify either 'llm_response' or '_llm_response'" raise TypeError(msg) - # Use whichever was provided - response_obj = _llm_response if _llm_response is not None else llm_response + 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 - # Set fields on frozen dataclass using object.__setattr__ object.__setattr__(self, "_llm_response", response_obj) object.__setattr__(self, "guardrail_results", guardrail_results) @property def llm_response(self) -> OpenAIResponseType: - """Access the underlying OpenAI response (deprecated). - - This property is provided for backward compatibility but is deprecated. - Users should access response attributes directly instead. + """Access underlying OpenAI response (deprecated). Returns: - The underlying OpenAI response object. + OpenAI response object. """ _warn_llm_response_deprecation(self) return self._llm_response def __getattr__(self, name: str) -> Any: - """Delegate attribute access to _llm_response for transparency. - - This method is called when an attribute is not found on GuardrailsResponse. - It delegates the access to the underlying _llm_response object, making - GuardrailsResponse act as a transparent proxy. + """Delegate attribute access to underlying OpenAI response. Args: - name: The attribute name being accessed. + name: Attribute name. Returns: - The attribute value from _llm_response. + Attribute value from OpenAI response. Raises: - AttributeError: If the attribute doesn't exist on _llm_response either. + AttributeError: If attribute doesn't exist. """ - # Access _llm_response directly without triggering deprecation warning return getattr(self._llm_response, name) def __dir__(self) -> list[str]: - """Support introspection by including delegated attributes. - - Returns a list of all attributes available on this object, including - both GuardrailsResponse's own attributes and all attributes from the - underlying _llm_response object. This enables proper IDE autocomplete - and interactive exploration. + """List all available attributes including delegated ones. Returns: - List of all available attribute names. + Sorted list of attribute names. """ - # Get GuardrailsResponse's own attributes own_attrs = set(object.__dir__(self)) - - # Get attributes from the underlying _llm_response delegated_attrs = set(dir(self._llm_response)) - - # Combine and return as sorted list return sorted(own_attrs | delegated_attrs) diff --git a/tests/unit/test_response_flattening.py b/tests/unit/test_response_flattening.py index 7efaf8d..9597043 100644 --- a/tests/unit/test_response_flattening.py +++ b/tests/unit/test_response_flattening.py @@ -1,9 +1,4 @@ -"""Tests for GuardrailsResponse flattening and deprecation warnings. - -This module tests that GuardrailsResponse acts as a transparent proxy to the -underlying OpenAI response, allowing direct attribute access while maintaining -backward compatibility with deprecation warnings for llm_response access. -""" +"""Tests for GuardrailsResponse attribute delegation and deprecation warnings.""" from __future__ import annotations @@ -61,9 +56,8 @@ def test_direct_attribute_access_works() -> None: guardrail_results=guardrail_results, ) - # Direct access should work without warnings with warnings.catch_warnings(): - warnings.simplefilter("error") # Turn warnings into errors + 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 @@ -80,7 +74,6 @@ def test_responses_api_direct_access_works() -> None: guardrail_results=guardrail_results, ) - # Direct access should work without warnings with warnings.catch_warnings(): warnings.simplefilter("error") assert response.id == "resp-123" # noqa: S101 @@ -98,9 +91,8 @@ def test_guardrail_results_access_no_warning() -> None: guardrail_results=guardrail_results, ) - # guardrail_results access should NOT emit warnings with warnings.catch_warnings(): - warnings.simplefilter("error") # Turn warnings into errors + 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 @@ -117,7 +109,6 @@ def test_llm_response_access_emits_deprecation_warning() -> None: guardrail_results=guardrail_results, ) - # Accessing llm_response should emit a deprecation warning with pytest.warns(DeprecationWarning, match="Accessing 'llm_response' is deprecated"): _ = response.llm_response @@ -132,13 +123,11 @@ def test_llm_response_chained_access_emits_warning() -> None: guardrail_results=guardrail_results, ) - # Accessing llm_response.attribute should emit a deprecation warning (first time) with pytest.warns(DeprecationWarning, match="Accessing 'llm_response' is deprecated"): _ = response.llm_response.id - # Accessing again should NOT emit another warning with warnings.catch_warnings(): - warnings.simplefilter("error") # Turn warnings into errors + warnings.simplefilter("error") _ = response.llm_response.model # Should not raise @@ -152,7 +141,6 @@ def test_hasattr_works_correctly() -> None: guardrail_results=guardrail_results, ) - # hasattr should work for delegated attributes with warnings.catch_warnings(): warnings.simplefilter("error") assert hasattr(response, "id") # noqa: S101 @@ -172,7 +160,6 @@ def test_getattr_works_correctly() -> None: guardrail_results=guardrail_results, ) - # getattr should work for delegated attributes with warnings.catch_warnings(): warnings.simplefilter("error") assert response.id == "chatcmpl-123" # noqa: S101 @@ -190,14 +177,12 @@ def test_attribute_error_for_missing_attributes() -> None: guardrail_results=guardrail_results, ) - # Accessing non-existent attributes should raise AttributeError with pytest.raises(AttributeError): _ = response.nonexistent_attribute def test_method_calls_work() -> None: """Test that method calls on delegated objects work correctly.""" - # Create a mock with a method mock_llm_response = SimpleNamespace( id="resp-123", custom_method=lambda: "method result", @@ -209,7 +194,6 @@ def test_method_calls_work() -> None: guardrail_results=guardrail_results, ) - # Method calls should work without warnings with warnings.catch_warnings(): warnings.simplefilter("error") assert response.custom_method() == "method result" # noqa: S101 @@ -271,7 +255,7 @@ def test_backward_compatibility_still_works() -> None: # Subsequent accesses should work without warnings with warnings.catch_warnings(): - warnings.simplefilter("error") # Turn warnings into errors + warnings.simplefilter("error") assert response.llm_response.model == "gpt-4" # noqa: S101 assert response.llm_response.choices[0].message.content == "Hello, world!" # noqa: S101 @@ -361,21 +345,26 @@ def test_init_backward_compatibility_with_llm_response_param() -> None: mock_llm_response = _create_mock_chat_completion() guardrail_results = _create_mock_guardrail_results() - # Old parameter name should still work (backward compatibility) + # 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 parameter name should work + # 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 should raise TypeError + # Both llm_response parameters should raise TypeError with pytest.raises(TypeError, match="Cannot specify both"): GuardrailsResponse( llm_response=mock_llm_response, @@ -383,10 +372,14 @@ def test_init_backward_compatibility_with_llm_response_param() -> None: guardrail_results=guardrail_results, ) - # Neither should raise TypeError + # 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."""