-
Notifications
You must be signed in to change notification settings - Fork 872
feat(traceloop-sdk): Guardrails functions #3618
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
📝 WalkthroughWalkthroughReplaces a decorator-based guardrail module with a new modular Guardrails framework (models, conditions, orchestration, on-failure handlers), adds multiple example guardrail workflows, extends Evaluator APIs (standalone run and as_guard), removes two legacy sample apps and old guardrails types, and adds extensive unit/integration tests and VCR cassettes. Changes
Sequence Diagram(s)sequenceDiagram
participant Client as Client
participant Guardrails as Guardrails
participant EvaluatorAPI as EvaluatorAPI
participant OnFailure as OnFailure
Client->>Guardrails: run(func_to_guard)
Guardrails->>Client: invoke guarded function → GuardedOutput(result, guard_inputs)
Guardrails->>EvaluatorAPI: execute guards/evaluators (parallel or sequential)
EvaluatorAPI-->>Guardrails: return pass/fail results
alt all guards pass
Guardrails-->>Client: return original result
else one or more guards fail
Guardrails->>OnFailure: invoke handler with GuardedOutput
OnFailure-->>Client: return fallback or raise GuardValidationError
end
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Poem
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
Comment |
…nk/guardrail_as_func
…nk/guardrail_as_func
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 15
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
packages/traceloop-sdk/traceloop/sdk/evaluator/model.py (1)
45-69: Fix typed_result to unwrap EvaluatorExecutionResult.
ExecutionResponse.resultwrapsevaluator_result, buttyped_resultunpacks the wrapper directly. This passes the BaseModel's field structure instead of the inner dict, causing the parse to fail. Use the inner dict instead.🛠️ Proposed fix
- return model(**self.result) + return model(**self.result.evaluator_result)
🤖 Fix all issues with AI agents
In
`@packages/opentelemetry-semantic-conventions-ai/opentelemetry/semconv_ai/__init__.py`:
- Around line 249-261: The GEN_AI_GUARDRAIL_* constants (GEN_AI_GUARDRAIL_NAME,
GEN_AI_GUARDRAIL_STATUS, GEN_AI_GUARDRAIL_DURATION,
GEN_AI_GUARDRAIL_GUARD_COUNT, GEN_AI_GUARDRAIL_FAILED_GUARD_COUNT,
GEN_AI_GUARDRAIL_GUARD_INDEX, GEN_AI_GUARDRAIL_GUARD_NAME,
GEN_AI_GUARDRAIL_GUARD_INPUT, GEN_AI_GUARDRAIL_GUARD_STATUS,
GEN_AI_GUARDRAIL_GUARD_DURATION, GEN_AI_GUARDRAIL_GUARD_ERROR_TYPE,
GEN_AI_GUARDRAIL_GUARD_ERROR_MESSAGE) are custom extensions not present in the
official OpenTelemetry GenAI spec; update the block around these constants in
__init__.py to include a brief inline comment stating they are
package-specific/custom extensions (mentioning that the official spec only has
provider-specific fields like aws.bedrock.guardrail.id) so consumers know they
are non-standard. Ensure the comment precedes the GEN_AI_GUARDRAIL_* definitions
and is concise but explicit about non-spec status.
In `@packages/sample-app/sample_app/guardrails/custom_evaluator_guard.py`:
- Around line 55-66: DiagnosisRequestInput and MedicalSafetyInput are defined
but never referenced and the file docstring mentions a missing "Medical safety
with professional referral fallback" example; either remove these unused
Pydantic models (DiagnosisRequestInput, MedicalSafetyInput) and clean up the
docstring, or implement the missing evaluator example that uses them (e.g., add
a MedicalSafetyGuard or DiagnosisRequestEvaluator function/class that accepts
DiagnosisRequestInput/MedicalSafetyInput and integrates with the existing
evaluation pipeline). Update any imports/comments to match the chosen approach
so there are no unused symbols left.
- Around line 167-175: The guard condition is inverted: in the guard creation
that uses diagnosis_blocker.as_guard and OnFailure.raise_exception, replace
Condition.is_false(field="pass") with Condition.is_true(field="pass") so the
blocker triggers (fails the guard) when the evaluator returns pass=False; update
the Condition call in the client.guardrails.create invocation accordingly to
ensure diagnosis requests are blocked.
In `@packages/sample-app/sample_app/guardrails/custom_function_guard.py`:
- Around line 7-13: The module docstring mentions "Shadow mode (evaluate but
don't block)" and "Async guard function" but those examples are not implemented;
either remove those lines from the module docstring or implement matching
examples. If you choose to implement, add a synchronous example function (e.g.,
shadow_mode_guard or a usage flag in the existing custom guard showing
evaluate-only behavior) and an async example function (e.g., async_guard) and
update the docstring examples to reference those function names and show their
usage; otherwise simply edit the top-level module docstring to only list the
examples that actually exist in custom_function_guard.py.
- Around line 223-226: The printed header for Example 4 is misleading: change
the header text printed before calling fallback_value_example() so it describes
returning a fallback on failure rather than "Shadow Mode (Evaluate but Don't
Block)"; locate the print block that prints "Example 4: Shadow Mode (Evaluate
but Don't Block)" (right above the await fallback_value_example() call) and
update the string to something like "Example 4: Return Fallback on Failure
(Evaluate and Replace)" or similar so the message matches the behavior
demonstrated by fallback_value_example().
In `@packages/sample-app/sample_app/guardrails/traceloop_evaluator_guard.py`:
- Around line 132-134: The code currently takes
completion.choices[0].message.content and appends it directly to
state.completions which can store None; update the logic around response to
guard against None by normalizing the value before appending/returning (e.g.,
read completion.choices[0].message.content into a local response variable,
replace None with an empty string or a clear placeholder, append that normalized
string via state.completions.append(...), and return the normalized response);
ensure you reference completion.choices[0].message.content, the local response
variable, and state.completions.append when making the change.
- Around line 87-91: The code uses completion.choices[0].message.content
directly which can be None; update the return in the function that builds the
GuardedOutput to defensively handle missing content from completion by
extracting content with a safe access (e.g., check completion and
completion.choices, ensure choices[0].message exists, and default to "" if
content is None) before creating GuardedOutput and ToxicityDetectorInput
(references: completion.choices[0].message.content, GuardedOutput,
ToxicityDetectorInput); ensure the code never passes None into result or
ToxicityDetectorInput.text but uses a safe empty-string fallback or
short-circuits to a sensible guarded result.
- Around line 54-58: The code reads completion.choices[0].message.content which
can be None; update the logic in the function that constructs the GuardedOutput
(the variable text used to build GuardedOutput and PIIDetectorInput) to coerce a
safe string (e.g., text = completion.choices[0].message.content or "") before
creating PIIDetectorInput(text=...) and returning GuardedOutput, so downstream
operations like result[:100] won’t raise TypeError; ensure all uses of text
(including any preview slices) handle an empty-string fallback.
In `@packages/sample-app/sample_app/guardrails/validate_example.py`:
- Around line 40-41: The OutputGuardInputs union is incorrect: it currently
lists PIIDetectorInput instead of the actual input used by GuardedOutput
(ToxicityDetectorInput), which will break type checking and clarity; update the
type alias OutputGuardInputs to include ToxicityDetectorInput and remove
PIIDetectorInput so it matches the guard_inputs used by GuardedOutput (also
check the analogous alias around lines 62-68 and make the same replacement),
ensuring the union and the guard_inputs list reference the same input types
(e.g., AnswerRelevancyInput, SexismDetectorInput, ToxicityDetectorInput).
- Around line 82-99: The current flow is unreachable because prompt_guardrail
was created with OnFailure.raise_exception, so await
prompt_guardrail.validate(...) will throw on violation and never set
prompt_is_safe to False; fix by either (A) changing the guardrail creation to a
non-raising handler (replace OnFailure.raise_exception with a non-raising
option) or (B) wrapping the validate call in a try/except that catches the
exception thrown by validate and sets prompt_is_safe = False (then proceed with
the existing fallback return); reference prompt_guardrail, validate,
OnFailure.raise_exception, PromptInjectionInput, and prompt_is_safe to locate
and apply the change.
In `@packages/traceloop-sdk/traceloop/sdk/guardrail/__init__.py`:
- Around line 7-28: The docstring example uses the outdated names
GuardedFunctionOutput and guard_input; update the example to use the public API
names GuardedOutput and guard_inputs instead (replace GuardedFunctionOutput[...]
with GuardedOutput[...] and guard_input={...} with guard_inputs={...}) so it
matches the exported symbols used by the guardrail runner (referencing
GuardedOutput and guard_inputs in the example).
In `@packages/traceloop-sdk/traceloop/sdk/guardrail/guardrail.py`:
- Around line 390-391: In the validate method, the
SpanAttributes.GEN_AI_GUARDRAIL_FAILED_GUARD_COUNT attribute is being set to the
list failed_indices instead of an integer; update the code in validate to pass
len(failed_indices) (the count) to span.set_attribute, mirroring the fix used in
run, and ensure you reference the failed_indices variable and
SpanAttributes.GEN_AI_GUARDRAIL_FAILED_GUARD_COUNT when making the change.
- Around line 309-310: The span attribute GEN_AI_GUARDRAIL_FAILED_GUARD_COUNT is
being set to failed_indices (a list) in the guardrail logic; change the value to
an integer count by passing len(failed_indices) to span.set_attribute instead of
the list. Update the call in the same block where failed_indices is checked (the
span.set_attribute invocation using
SpanAttributes.GEN_AI_GUARDRAIL_FAILED_GUARD_COUNT) so the attribute stores the
number of failed guards rather than the list itself.
- Around line 173-176: The code sets
span.set_attribute(SpanAttributes.GEN_AI_GUARDRAIL_GUARD_CONDITION, condition)
but SpanAttributes lacks GEN_AI_GUARDRAIL_GUARD_CONDITION; either add that
constant to the SpanAttributes enum/object in
opentelemetry/semconv_ai/__init__.py (matching naming/style of the other
GEN_AI_GUARDRAIL_* attributes) or remove the block that references
guard._condition in guardrail.py (the lines using guard, condition, and
span.set_attribute) so no undefined attribute is accessed; prefer adding the new
SpanAttributes entry to keep telemetry consistent.
In `@packages/traceloop-sdk/traceloop/sdk/guardrail/on_failure.py`:
- Around line 38-58: OnFailure.log currently logs the full guard_inputs which
can leak PII; update the signature of OnFailure.log (and its inner handler) to
avoid emitting raw guard_inputs by default (e.g., add an optional parameter like
include_inputs: bool = False or redact: bool = True), and change the handler to
log only safe metadata (such as keys, types, or a redacted summary) when
include_inputs is False, while still allowing callers to opt-in to full input
logging by passing the flag or providing a custom handler; refer to
OnFailure.log and its inner handler(output: GuardedOutput[Any, Any]) and replace
direct use of output.guard_inputs in logger.log with the safe summary/redaction
logic.
🧹 Nitpick comments (2)
packages/traceloop-sdk/traceloop/sdk/guardrail/guardrail.py (1)
51-60: Type annotation inconsistency for_on_failure.
_on_failureis typed asOnFailureHandlerbut initialized toNone. Consider usingOptional[OnFailureHandler]for consistency with the runtime value.Suggested fix
_on_failure: OnFailureHandler +_on_failure: Optional[OnFailureHandler]packages/sample-app/sample_app/guardrails/multiple_guards_example.py (1)
275-303: Multiple examples are commented out.Most examples in
main()are commented out, with only Example 2 running. This may confuse users trying to run all examples. Consider either:
- Uncommenting the examples so users can run them all
- Adding a comment explaining why certain examples are disabled (e.g., require specific setup)
packages/opentelemetry-semantic-conventions-ai/opentelemetry/semconv_ai/__init__.py
Outdated
Show resolved
Hide resolved
packages/sample-app/sample_app/guardrails/custom_evaluator_guard.py
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 5
🤖 Fix all issues with AI agents
In `@packages/sample-app/sample_app/guardrails/custom_evaluator_guard.py`:
- Around line 137-144: The assignment text =
completion.choices[0].message.content can be None; update the code in
custom_evaluator_guard.py (the block that builds and returns GuardedOutput) to
use a safe fallback, e.g. set text = completion.choices[0].message.content or ""
before returning GuardedOutput(result=text, ...), so GuardedOutput.result is
never None; adjust anywhere this pattern appears in the same function or method.
- Around line 86-92: The code reads completion.choices[0].message.content
directly and can be None; update the extraction in custom_evaluator_guard (where
GuardedOutput and MedicalAdviceInput are constructed) to use a safe fallback
(e.g., text = completion.choices[0].message.content or "") and also guard any
later slicing or inspections (e.g., replace result[:200] with (result or
"")[:200]) so MedicalAdviceInput(text=...) and subsequent operations never
receive None.
In `@packages/sample-app/sample_app/guardrails/custom_function_guard.py`:
- Around line 50-54: The code reads completion.choices[0].message.content
directly and may get None; update each occurrence to coerce to a string before
use (e.g., text = completion.choices[0].message.content or "") and then use that
text for GuardedOutput and word_count (guard_inputs=[{"text": text,
"word_count": len(text.split())}]) so slicing and split calls are safe; change
every place that builds a GuardedOutput from
completion.choices[0].message.content (the occurrences reported) to use this
fallback and ensure any subsequent slicing/formatting uses the safe text
variable.
In `@packages/sample-app/sample_app/guardrails/traceloop_evaluator_guard.py`:
- Around line 93-104: In toxicity_guard_example update the Condition.equals
check to match the evaluator's response schema: change the Condition.equals
value from "pass" to "safe" for the field "is_safe" used by
EvaluatorMadeByTraceloop.toxicity_detector so the guard correctly recognizes
safe content when running guardrail.run(generate_content).
In `@packages/traceloop-sdk/traceloop/sdk/guardrail/guardrail.py`:
- Around line 205-215: The _run_guards_parallel method currently builds
coroutines and awaits them with asyncio.gather which can lose the current
OpenTelemetry span context; change it to wrap each coroutine returned by
self._run_single_guard(...) with asyncio.create_task(...) so tasks are created
under the active context (mirroring the pattern used in experiment.py), then
await asyncio.gather(*tasks); while doing this, ensure any contextvars/span
propagation code used elsewhere is applied so child spans remain children of the
guardrail.run span and add tests that assert parent-child span relationships for
_run_guards_parallel.
🧹 Nitpick comments (3)
packages/traceloop-sdk/traceloop/sdk/guardrail/guardrail.py (2)
56-63: Type hint mismatch for_on_failureinitialization.
_on_failureis typed asOnFailureHandlerbut initialized toNone. This could cause type checking issues. Consider usingOptional[OnFailureHandler]for the type hint or initializing with a default handler.♻️ Suggested fix
+from typing import Callable, Awaitable, cast, Optional, Union + class Guardrails: ... _evaluator: Evaluator _async_http: httpx.AsyncClient _guards: list[Guard] - _on_failure: OnFailureHandler + _on_failure: Optional[OnFailureHandler] _run_all: bool
393-398: Failure handler result is awaited but discarded invalidate().In the
validate()method, when a failure handler is called, the result is awaited if it's a coroutine (line 397-398), but the awaited value is not used. This is inconsistent with therun()method which returns the failure result. Whilevalidate()returns a bool, this could lead to unexpected behavior if users expect the handler's side effects to complete before the method returns.packages/sample-app/sample_app/guardrails/custom_function_guard.py (1)
152-156: Custom handler logs fullguard_inputs(potential PII exposure).The
custom_alert_handlerprints the fulloutput.guard_inputsat line 155. While this is a sample/demo file, it may establish a pattern that users copy. Consider adding a comment noting this is for demonstration purposes only and should not log full inputs in production.📝 Suggested documentation
def custom_alert_handler(output: GuardedOutput) -> None: - """Custom handler that logs and could send alerts.""" + """Custom handler that logs and could send alerts. + + Note: In production, avoid logging full guard_inputs as they may contain PII. + """ print(f"[ALERT] Guard failed for output: {str(output.result)[:50]}...") print(f"[ALERT] Guard inputs was: {output.guard_inputs}")
packages/sample-app/sample_app/guardrails/custom_evaluator_guard.py
Outdated
Show resolved
Hide resolved
| text = completion.choices[0].message.content | ||
| return GuardedOutput( | ||
| result=text, | ||
| guard_inputs=[{"text": text, "word_count": len(text.split())}], | ||
| ) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Handle potential None value from completion content.
Multiple locations access completion.choices[0].message.content without the or "" fallback. If content is None, the guard input will contain None and operations like slicing the result may fail.
Affected lines: 50, 112, 146, 187.
🛡️ Suggested fix for all occurrences
- text = completion.choices[0].message.content
+ text = completion.choices[0].message.content or ""Also applies to: 112-116, 146-150, 187-191
🤖 Prompt for AI Agents
In `@packages/sample-app/sample_app/guardrails/custom_function_guard.py` around
lines 50 - 54, The code reads completion.choices[0].message.content directly and
may get None; update each occurrence to coerce to a string before use (e.g.,
text = completion.choices[0].message.content or "") and then use that text for
GuardedOutput and word_count (guard_inputs=[{"text": text, "word_count":
len(text.split())}]) so slicing and split calls are safe; change every place
that builds a GuardedOutput from completion.choices[0].message.content (the
occurrences reported) to use this fallback and ensure any subsequent
slicing/formatting uses the safe text variable.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why did this change so much?
| guardrail = client.guardrails.create( | ||
| guards=[EvaluatorMadeByTraceloop.pii_detector( | ||
| probability_threshold=0.7 | ||
| ).as_guard(condition=Condition.is_false(field="has_pii"), timeout_in_sec=45)], |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
how does the user knows to use "has_pii"?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 3
🤖 Fix all issues with AI agents
In `@packages/sample-app/sample_app/guardrails/validate_example.py`:
- Around line 124-128: The print "Output validation passed." is unconditional
and will fire even if output_guardrail.run(...) returned the
OnFailure.return_value() fallback; change the code to detect the fallback and
only print on true success: after calling result = await
output_guardrail.run(lambda: generate_response(user_prompt)), obtain the
configured fallback value from the guardrail (the OnFailure.return_value() used
by output_guardrail) and compare result to that fallback, or otherwise use the
guardrail API to check whether the run returned a fallback; only print "Output
validation passed." when the result does not equal the fallback (i.e., guard
passed), otherwise handle/log the fallback case.
In `@packages/traceloop-sdk/traceloop/sdk/guardrail/guardrail.py`:
- Around line 186-195: The code currently writes the guard evaluator condition
and the guard_input to the same span attribute GEN_AI_GUARDRAIL_INPUT, so the
second write overwrites the first; update the logic in guardrail.py around the
span.set_attribute calls (referencing guard._condition, guard_input, and
span.set_attribute) to preserve both values by either using a dedicated
attribute name (e.g., GEN_AI_GUARDRAIL_CONDITION) for the condition or by
combining condition and guard_input into a single JSON object/string before
calling span.set_attribute, and retain the existing fallback that stringifies
non-serializable guard_input.
In `@packages/traceloop-sdk/traceloop/sdk/guardrail/span_attributes.py`:
- Around line 1-8: Replace the custom gen_ai.guardrail.* constants with
standardized OpenTelemetry security_rule.* attributes: update
GEN_AI_GUARDRAIL_NAME, GEN_AI_GUARDRAIL_STATUS, GEN_AI_GUARDRAIL_DURATION,
GEN_AI_GUARDRAIL_GUARD_COUNT, GEN_AI_GUARDRAIL_FAILED_GUARD_COUNT,
GEN_AI_GUARDRAIL_INPUT, GEN_AI_GUARDRAIL_ERROR_TYPE, and
GEN_AI_GUARDRAIL_ERROR_MESSAGE to use the security_rule.* namespace and
appropriate semantic fields (e.g., map name → security_rule.name,
description/status → security_rule.description or category as appropriate, use
security_rule.ruleset.name or security_rule.uuid for rule identifiers, and move
error/metadata into security_rule.reference/license fields as fits the spec) so
the constants conform to OpenTelemetry GenAI/security_rule naming and
interoperability.
🧹 Nitpick comments (3)
packages/sample-app/sample_app/guardrails/validate_example.py (1)
95-101: Redundant validation check after exception handling.After catching
GuardValidationErrorand returning early at line 98, theprompt_is_safevariable will always beTruewhen execution reaches line 100 (sincevalidate()returnsbooland only raises on failure when configured withraise_exception). The check at lines 100-101 is unreachable dead code.♻️ Proposed simplification
# Validate user input BEFORE calling the LLM print(f"Validating user input: '{user_prompt[:50]}...'") try: - prompt_is_safe = await prompt_guardrail.validate([PromptInjectionInput(prompt=user_prompt)]) + await prompt_guardrail.validate([PromptInjectionInput(prompt=user_prompt)]) except GuardValidationError: return "I'm sorry, I can't process that request." - - if not prompt_is_safe: - return "I'm sorry, I can't process that request."packages/traceloop-sdk/traceloop/sdk/guardrail/guardrail.py (2)
60-75: Type hint inconsistency for_on_failure.
_on_failureis typed asOnFailureHandler(line 63) but initialized toNone(line 72). Sincecreate()must be called beforerun()orvalidate(), this works at runtime, but the type annotation could be more accurate.♻️ Suggested type fix
- _on_failure: OnFailureHandler + _on_failure: Optional[OnFailureHandler]
400-403:validate()lacks parent span tracing unlikerun().The
run()method creates a parent span (guardrail.run) with aggregated attributes (status, duration, guard count). However,validate()calls_execute_guards_with_tracing()without a span or start_time, so no aggregated guardrail-level span is created—only individual guard spans.This may be intentional for lightweight validation, but creates an inconsistency in observability between the two methods.
♻️ Add parent span to validate() for consistency
async def validate( self, guard_inputs: list[GuardInput], on_failure: Optional[OnFailureHandler] = None, ) -> bool: ... if not self._guards: raise ValueError("Must call create() before validate()") failure_handler = on_failure if on_failure is not None else self._on_failure with get_tracer() as tracer: - all_passed, _ = await self._execute_guards_with_tracing( - guard_inputs, tracer - ) + span_name = f"{self._name}.guardrail" if self._name else "guardrail" + with tracer.start_as_current_span(span_name) as span: + start_time = time.perf_counter() + span.set_attribute(GenAIAttributes.GEN_AI_OPERATION_NAME, "guardrail.validate") + if self._name: + span.set_attribute(GEN_AI_GUARDRAIL_NAME, self._name) + + all_passed, _ = await self._execute_guards_with_tracing( + guard_inputs, tracer, span, start_time + ) # Handle failure ...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 3
🤖 Fix all issues with AI agents
In `@packages/sample-app/sample_app/guardrails/multiple_guards_example.py`:
- Around line 41-55: In generate_content(), avoid calling .split(), .isupper(),
or len on a None message by making the extracted content None-safe: when reading
completion.choices[0].message.content (symbol: completion, variable: text),
default it to an empty string (e.g., text =
completion.choices[0].message.content or "") before computing word_count,
caps_count, and caps_ratio; apply the same None-safe default pattern to the
other occurrences mentioned so they don't crash when message.content is None.
In `@packages/sample-app/sample_app/guardrails/validate_example.py`:
- Around line 47-68: The generate_response function must handle a possible None
from completion.choices[0].message.content before constructing the
GuardedOutput; update generate_response to coalesce the optional content to a
safe string (e.g., use an empty string or a descriptive fallback) and use that
string for result and for AnswerRelevancyInput/ SexismDetectorInput/
ToxicityDetectorInput so Pydantic validators receive a str; optionally log or
record when content was None to aid debugging.
In `@packages/traceloop-sdk/tests/guardrails/test_integration.py`:
- Around line 64-112: Update the VCR configuration in
tests/guardrails/conftest.py by adding a before_record hook (in addition to
existing filter_headers) that sanitizes PII from both request and response
bodies before writing cassettes: implement a before_record function that uses
regexes to scrub emails (e.g., john.doe@example.com), phone numbers, and other
sensitive patterns and replace them with safe placeholders, register it with the
VCR configuration (before_record=your_sanitizer), then re-run and re-record the
VCR cassettes used by tests marked with `@pytest.mark.vcr` (e.g.,
tests/guardrails/test_integration.py) so the saved cassettes no longer contain
PII.
🧹 Nitpick comments (2)
packages/traceloop-sdk/traceloop/sdk/evaluator/config.py (2)
103-110: Make unsupported guard inputs fail with a clear error.The
dict(input_data)fallback can raise a crypticTypeError. Consider surfacing a clearer message when input isn’t adictorBaseModel.♻️ Proposed improvement
if isinstance(input_data, dict): input_dict = input_data elif hasattr(input_data, "model_dump"): input_dict = input_data.model_dump() else: - # Fallback: try to convert to dict - input_dict = dict(input_data) + # Fallback: try to convert to dict, but surface a clear error + try: + input_dict = dict(input_data) + except Exception as exc: + raise TypeError( + "guard input must be a dict or a Pydantic BaseModel" + ) from exc
124-126: Provide a clearer error whencondition_fieldis missing.Indexing directly can raise a
KeyErrorwithout context. A more explicit error will be easier to debug for users wiring custom evaluators.♻️ Proposed improvement
if self.condition_field: - result_to_validate = eval_response.result.evaluator_result[self.condition_field] + try: + result_to_validate = eval_response.result.evaluator_result[self.condition_field] + except KeyError as exc: + raise ValueError( + f"Evaluator result missing field '{self.condition_field}'" + ) from exc else: result_to_validate = eval_response.result.evaluator_result
| async def generate_content() -> GuardedOutput[str, dict]: | ||
| """Generate content with multiple validation inputs.""" | ||
| completion = await openai_client.chat.completions.create( | ||
| model="gpt-4o-mini", | ||
| messages=[ | ||
| { | ||
| "role": "user", | ||
| "content": "Write a brief travel tip about visiting Japan.", | ||
| } | ||
| ], | ||
| ) | ||
| text = completion.choices[0].message.content | ||
| word_count = len(text.split()) | ||
| caps_count = sum(1 for c in text if c.isupper()) | ||
| caps_ratio = caps_count / max(len(text), 1) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🌐 Web query:
OpenAI Chat Completions API message.content null None handling
💡 Result:
choices[0].message.content can be null / None in the Chat Completions API in normal, expected situations—so your code should branch on why it’s missing instead of assuming it’s always a string.
When message.content is None (and what to do)
-
The model decided to call a tool (function)
- In Chat Completions, the assistant message
contentis “required unlesstool_calls(or deprecatedfunction_call) is specified”—so tool-call turns often havecontent = nulland a populatedmessage.tool_calls. [1] - Handle by checking:
choice.finish_reason == "tool_calls"and then readingchoice.message.tool_calls. [2]
- In Chat Completions, the assistant message
-
Content was omitted by safety filtering
finish_reasoncan becontent_filter“if content was omitted due to a flag from our content filters”. In this case,contentmay be missing/empty. [2]- Handle by surfacing a “can’t display that response” error, retrying with a safer prompt, etc.
-
Streaming responses
- In streaming, each chunk’s
choices[].delta.contentis optional and many chunks won’t include it (e.g., role-only chunks, tool-call chunks, final chunk). You must accumulate only the chunks that containdelta.contentand separately accumulate anydelta.tool_calls. [3]
- In streaming, each chunk’s
Practical handling pattern
- Prefer:
- If
choice.message.tool_callsexists → process tool calls (don’t readcontent). - Else if
choice.message.contentexists → display it. - Else if
choice.finish_reason == "content_filter"→ treat as filtered. - Else → treat as an unexpected empty output (log and inspect the raw response).
- If
Common “None” pitfall when sending messages (Python SDK)
If you construct messages yourself, don’t explicitly set fields like tool_calls=None; omit them entirely—setting None can cause schema/type errors (e.g., “None is not of type array”). [4]
Sources: Chat Completions message schema + finish_reason meanings [1][2], streaming chunk schema [3], openai-python issue documenting tool_calls=None pitfall [4].
🏁 Script executed:
fd "multiple_guards_example.py" --type fRepository: traceloop/openllmetry
Length of output: 133
🏁 Script executed:
wc -l packages/sample-app/sample_app/guardrails/multiple_guards_example.pyRepository: traceloop/openllmetry
Length of output: 137
🏁 Script executed:
cat -n packages/sample-app/sample_app/guardrails/multiple_guards_example.py | sed -n '35,60p; 107,135p; 218,245p'Repository: traceloop/openllmetry
Length of output: 3496
Add None-safe handling for OpenAI content.
message.content is None in normal scenarios: when the model calls a tool (specified in tool_calls), or when content is filtered by safety systems. Using it directly causes crashes on .split(), .isupper(), and downstream guard inputs.
The proposed fix handles the common case. For more robust handling, check finish_reason and tool_calls first—but or "" prevents immediate breakage.
🛡️ Proposed fix
- text = completion.choices[0].message.content
+ text = completion.choices[0].message.content or ""Also applies to: lines 128, 235
🤖 Prompt for AI Agents
In `@packages/sample-app/sample_app/guardrails/multiple_guards_example.py` around
lines 41 - 55, In generate_content(), avoid calling .split(), .isupper(), or len
on a None message by making the extracted content None-safe: when reading
completion.choices[0].message.content (symbol: completion, variable: text),
default it to an empty string (e.g., text =
completion.choices[0].message.content or "") before computing word_count,
caps_count, and caps_ratio; apply the same None-safe default pattern to the
other occurrences mentioned so they don't crash when message.content is None.
| async def generate_response(user_prompt: str) -> GuardedOutput[str, OutputGuardInputs]: | ||
| """Generate LLM response and prepare it for toxicity checking.""" | ||
| completion = await openai_client.chat.completions.create( | ||
| model="gpt-4o-mini", | ||
| messages=[ | ||
| { | ||
| "role": "system", | ||
| "content": "You are a helpful assistant. Provide clear, safe, and accurate responses.", | ||
| }, | ||
| {"role": "user", "content": user_prompt}, | ||
| ], | ||
| ) | ||
| response_text = completion.choices[0].message.content | ||
|
|
||
| return GuardedOutput( | ||
| result=response_text, | ||
| guard_inputs=[ | ||
| AnswerRelevancyInput(answer=response_text, question=user_prompt), | ||
| SexismDetectorInput(text=response_text), | ||
| ToxicityDetectorInput(text=response_text), | ||
| ], | ||
| ) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🌐 Web query:
OpenAI Chat Completions API message.content null None handling
💡 Result:
In the Chat Completions API, choices[0].message.content can legitimately be null/missing in a few cases—most commonly when the assistant is requesting tool/function calls.
When message.content can be null
- Tool calling: For an assistant message,
contentis “Required unlesstool_callsorfunction_callis specified.” So when the model returnstool_calls,contentmay be omitted ornull. [1] - Some tool-calling responses in the wild (incl. Azure OpenAI) explicitly show
"content": nullalongside"tool_calls": [...]. [3]
How to handle it (recommended pattern)
- If
message.tool_callsis present and non-empty: execute the tool(s); don’t expect text inmessage.content. - Else: treat
message.contentas the assistant’s text (but still defensively handlenull).
Pseudo-logic:
msg = resp["choices"][0]["message"]
if msg.get("tool_calls"):
# handle tool calls
...
else:
text = msg.get("content") or ""
...Important: don’t send None for fields expecting arrays
If you are constructing messages to send back into /v1/chat/completions, omit optional fields rather than setting them to None. For example, setting tool_calls=None can cause a 400 because the API expects an array when the field is present. [2]
Sources: OpenAI Chat Completions message schema (assistant content vs tool_calls) [1]; openai-python issue illustrating tool_calls=None → schema error [2]; Azure example showing content: null with tool_calls [3].
🏁 Script executed:
# Read the full file to understand context
cat -n packages/sample-app/sample_app/guardrails/validate_example.pyRepository: traceloop/openllmetry
Length of output: 6520
🏁 Script executed:
# Find where openai-python types are defined or used
python3 << 'EOF'
import sys
try:
from openai.types.chat import ChatCompletion
import inspect
# Get the ChatCompletion class
print("ChatCompletion signature:")
print(inspect.signature(ChatCompletion))
# Try to get the message type info
from openai.types.chat.chat_completion import Choice
print("\nChoice class:")
print(inspect.getsource(Choice))
except Exception as e:
print(f"Error: {e}")
print("\nTrying alternative approach...")
import openai
print(f"OpenAI version: {openai.__version__}")
EOFRepository: traceloop/openllmetry
Length of output: 454
🌐 Web query:
openai-python library message.content type Optional[str] ChatCompletion
💡 Result:
In the openai-python SDK, ChatCompletion.choices[0].message is a ChatCompletionMessage, and its content field is typed as:
content: Optional[str](i.e.,str | None) [1]
It’s optional because, per the Chat Completions API schema, an assistant message’s content is not required when the model returns a tool_calls/function_call instead (and is only required otherwise) [2]. So message.content can legitimately be None even in a successful response. [2]
Sources: [1] [2]
Handle possible None for OpenAI response content.
The message.content field is typed as Optional[str] in the openai-python library and can legitimately be None (particularly when the model returns tool calls). Without a fallback, passing None to the Pydantic validators will fail.
🛡️ Proposed fix
- response_text = completion.choices[0].message.content
+ response_text = completion.choices[0].message.content or ""🤖 Prompt for AI Agents
In `@packages/sample-app/sample_app/guardrails/validate_example.py` around lines
47 - 68, The generate_response function must handle a possible None from
completion.choices[0].message.content before constructing the GuardedOutput;
update generate_response to coalesce the optional content to a safe string
(e.g., use an empty string or a descriptive fallback) and use that string for
result and for AnswerRelevancyInput/ SexismDetectorInput/ ToxicityDetectorInput
so Pydantic validators receive a str; optionally log or record when content was
None to aid debugging.
| class TestPIIDetectorGuard: | ||
| """Tests for PII detector evaluator as guard.""" | ||
|
|
||
| @pytest.mark.vcr | ||
| @pytest.mark.anyio | ||
| async def test_pii_detector_guard_passes_clean_text( | ||
| self, guardrails, traceloop_client | ||
| ): | ||
| """PII detector guard passes when text has no PII.""" | ||
| guard = EvaluatorMadeByTraceloop.pii_detector().as_guard( | ||
| condition=Condition.is_false() | ||
| ) | ||
|
|
||
| g = guardrails.create( | ||
| guards=[guard], | ||
| on_failure=OnFailure.raise_exception("PII detected"), | ||
| name="pii-check", | ||
| ) | ||
|
|
||
| # Clean text without PII | ||
| passed = await g.validate([ | ||
| PIIDetectorInput(text="Hello, this is a simple message without any personal information.") | ||
| ]) | ||
|
|
||
| assert passed is True | ||
|
|
||
| @pytest.mark.vcr | ||
| @pytest.mark.anyio | ||
| async def test_pii_detector_guard_fails_with_email( | ||
| self, guardrails, traceloop_client | ||
| ): | ||
| """PII detector guard fails when text contains email.""" | ||
| guard = EvaluatorMadeByTraceloop.pii_detector().as_guard( | ||
| condition=Condition.is_false() | ||
| ) | ||
|
|
||
| g = guardrails.create( | ||
| guards=[guard], | ||
| on_failure=OnFailure.log(), | ||
| name="pii-check", | ||
| ) | ||
|
|
||
| # Text with PII (email) | ||
| passed = await g.validate([ | ||
| PIIDetectorInput(text="Contact me at john.doe@example.com for more details.") | ||
| ]) | ||
|
|
||
| assert passed is False | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check directory structure for guardrails tests
ls -la packages/traceloop-sdk/tests/guardrails/ 2>/dev/null || echo "Directory not found"Repository: traceloop/openllmetry
Length of output: 711
🏁 Script executed:
#!/bin/bash
# Find conftest.py files in the guardrails test directory
find packages/traceloop-sdk/tests/guardrails -name "conftest.py" -type fRepository: traceloop/openllmetry
Length of output: 116
🏁 Script executed:
#!/bin/bash
# Look for VCR configuration in conftest files
find packages/traceloop-sdk/tests -name "conftest.py" -type f | head -10 | xargs -I {} sh -c 'echo "=== {} ===" && cat -n "{}"'Repository: traceloop/openllmetry
Length of output: 11008
🏁 Script executed:
#!/bin/bash
# Find cassette files in guardrails test directory
find packages/traceloop-sdk/tests/guardrails -name "*.yaml" -o -name "*.yml" | head -20Repository: traceloop/openllmetry
Length of output: 1191
🏁 Script executed:
#!/bin/bash
# Check for any pytest-vcr or vcrpy configuration
rg -l "pytest.*vcr|vcrpy|@pytest.mark.vcr" packages/traceloop-sdk/tests --type py | head -5Repository: traceloop/openllmetry
Length of output: 321
🏁 Script executed:
#!/bin/bash
# Check size and inspect the PII detector cassettes
wc -l packages/traceloop-sdk/tests/guardrails/cassettes/test_integration/TestPIIDetectorGuard.*.yamlRepository: traceloop/openllmetry
Length of output: 354
🏁 Script executed:
#!/bin/bash
# Search for PII in the cassettes - emails and sensitive data
echo "=== Searching for emails in cassettes ==="
rg "john\.doe|example\.com|Bearer|api.key|TRACELOOP" \
packages/traceloop-sdk/tests/guardrails/cassettes/test_integration/TestPIIDetectorGuard.*.yaml
echo ""
echo "=== Checking if cassette contains the test input text ==="
rg "Hello.*simple message|john\.doe@example\.com|Contact me at" \
packages/traceloop-sdk/tests/guardrails/cassettes/test_integration/TestPIIDetectorGuard.*.yamlRepository: traceloop/openllmetry
Length of output: 832
🏁 Script executed:
#!/bin/bash
# Read one of the cassettes to see structure
head -100 packages/traceloop-sdk/tests/guardrails/cassettes/test_integration/TestPIIDetectorGuard.test_pii_detector_guard_fails_with_email.yamlRepository: traceloop/openllmetry
Length of output: 2523
🏁 Script executed:
#!/antml/bash
# Check if there's a before_record configuration anywhere
rg "before_record|before_record_request|before_record_response|scrub|sanitize" \
packages/traceloop-sdk/tests/guardrails --type pyRepository: traceloop/openllmetry
Length of output: 47
Add before_record filter to sanitize PII from VCR cassettes.
The VCR configuration in packages/traceloop-sdk/tests/guardrails/conftest.py filters authorization headers, but does not sanitize request/response bodies. The cassettes contain test input data including the email address "john.doe@example.com" in request bodies, which violates the security guideline: "Ensure VCR cassettes never contain secrets or PII; scrub them using VCR filters (e.g., filter_headers, before_record) or equivalent test framework methods."
Add a before_record filter to sanitize email addresses and other PII patterns before cassettes are written to disk, then re-record the cassettes.
🤖 Prompt for AI Agents
In `@packages/traceloop-sdk/tests/guardrails/test_integration.py` around lines 64
- 112, Update the VCR configuration in tests/guardrails/conftest.py by adding a
before_record hook (in addition to existing filter_headers) that sanitizes PII
from both request and response bodies before writing cassettes: implement a
before_record function that uses regexes to scrub emails (e.g.,
john.doe@example.com), phone numbers, and other sensitive patterns and replace
them with safe placeholders, register it with the VCR configuration
(before_record=your_sanitizer), then re-run and re-record the VCR cassettes used
by tests marked with `@pytest.mark.vcr` (e.g.,
tests/guardrails/test_integration.py) so the saved cassettes no longer contain
PII.
feat(instrumentation): ...orfix(instrumentation): ....Summary by CodeRabbit
New Features
Examples
Tests
✏️ Tip: You can customize this high-level summary in your review settings.