From b162db860e1e559639389f22225096585afb109e Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:21:59 +0200 Subject: [PATCH 01/11] feat(server): derive guarded text bindings from typed array variants Responses output interleaves metadata with a single assistant message. Declare branch selection and cardinality alongside object policy, and derive the existing buffered and stream selectors without duplicating extraction paths. Export only disjoint required-literal object unions and retain fail-closed checks for overlapping branches and hidden subjects. Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- .../provider/projection_policy.py | 126 ++++++++- .../experimental/provider/stream_policy.py | 17 +- .../experimental/test_projection_variants.py | 246 ++++++++++++++++++ 3 files changed, 376 insertions(+), 13 deletions(-) create mode 100644 tests/server/experimental/test_projection_variants.py diff --git a/nemoguardrails/server/experimental/provider/projection_policy.py b/nemoguardrails/server/experimental/provider/projection_policy.py index 3761fec6ca..fe2e047be9 100644 --- a/nemoguardrails/server/experimental/provider/projection_policy.py +++ b/nemoguardrails/server/experimental/provider/projection_policy.py @@ -33,6 +33,7 @@ from typing_extensions import Self from nemoguardrails.server.experimental.provider.payload import ( + GuardedArraySelection, GuardedTextLocation, PayloadCapabilityProfile, PayloadProjectionContract, @@ -56,6 +57,29 @@ def _check_reason(reason: str) -> None: raise ValueError(f"Reason {reason!r} must be a structured reason such as 'core_capability.tool_content'") +@dataclass(frozen=True) +class ArrayVariant: + """Identify one reviewed object branch in a heterogeneous array. + + Match keys must be required singleton Literal fields of the owning model. + Cardinality applies when a text binding selects this branch from an array, + not when the object is validated in isolation. Non-text branches use 'any'. + The same declaration describes selection in exported guard contracts. + """ + + match: tuple[tuple[str, str | int | bool], ...] + cardinality: Literal["any", "at_most_one", "exactly_one"] + + def __post_init__(self) -> None: + """Reject ambiguous or unrepresentable selector declarations.""" + if not self.match or len(dict(self.match)) != len(self.match): + raise ValueError("Array variant match must have unique named discriminators") + if any(not isinstance(key, str) or not key or type(value) not in (str, int, bool) for key, value in self.match): + raise ValueError("Array variant match requires names and string, integer, or boolean values") + if self.cardinality not in {"any", "at_most_one", "exactly_one"}: + raise ValueError("Unsupported array variant cardinality") + + @dataclass(frozen=True) class ObjectPolicy: """Describe the reviewed boundary of one JSON object. @@ -69,6 +93,8 @@ class ObjectPolicy: rejects them. "configurable" rejects them unless validation explicitly allows unknown content fields, which is reserved for trusted configuration. Members listed in opaque are reviewed. + variant: Optional discriminator and selection cardinality when this + model is a branch of a guarded heterogeneous array. This metadata does not select guardrails, a deployment configuration, or a capability profile. Field-specific policy belongs on the field annotations. @@ -77,6 +103,7 @@ class ObjectPolicy: source: str | None = None opaque: tuple[str, ...] = () unknown_fields: Literal["forbid", "configurable"] = "forbid" + variant: ArrayVariant | None = None def __post_init__(self) -> None: """Reject values the exported contract format cannot represent.""" @@ -121,6 +148,21 @@ def __pydantic_init_subclass__(cls, **kwargs: Any) -> None: field.annotation is not type(None) or field.is_required() or field.default is not None ): raise ValueError(f"{cls.__name__}.{name}: disabled fields must be optional and null-only") + if cls.policy.variant is not None: + for name, expected in cls.policy.variant.match: + field = cls.model_fields.get(name) + if ( + field is None + or not field.is_required() + or get_origin(field.annotation) is not Literal + or len(get_args(field.annotation)) != 1 + or type(get_args(field.annotation)[0]) is not type(expected) + or get_args(field.annotation)[0] != expected + or field_policy(field).get("classification") != "constrained" + ): + raise ValueError( + f"{cls.__name__}.{name}: variant match requires a required matching constrained Literal" + ) @classmethod def __get_pydantic_json_schema__(cls, core_schema: CoreSchema, handler: GetJsonSchemaHandler) -> JsonSchemaValue: @@ -139,6 +181,11 @@ def __get_pydantic_json_schema__(cls, core_schema: CoreSchema, handler: GetJsonS node["additionalProperties"] = False metadata["unknown_fields"] = cls.policy.unknown_fields metadata["reject_case_aliases"] = True + if cls.policy.variant is not None: + metadata["variant"] = { + "match": dict(cls.policy.variant.match), + "cardinality": cls.policy.variant.cardinality, + } properties = { **node["properties"], **{name: {EXTENSION: {"classification": "opaque"}} for name in cls.policy.opaque}, @@ -330,6 +377,39 @@ def field_coverage(model: type[PolicyModel]) -> ProjectionFieldCoverage: ) +def guarded_array_variant(annotation: Any) -> tuple[type[PolicyModel], ArrayVariant] | None: + """Resolve the one text-bearing branch of a reviewed object union. + + Every branch must declare a mutually exclusive discriminator. The selected + branch has bounded cardinality; other branches must not hide guarded text. + Return None for a non-union item, whose ordinary array bounds apply instead. + No JSON Schema generation or provider lookup participates in binding. + """ + if get_origin(annotation) not in (Union, UnionType): + return None + branches: list[tuple[type[PolicyModel], ArrayVariant]] = [] + for model in get_args(annotation): + if not isinstance(model, type) or not issubclass(model, PolicyModel) or model.policy.variant is None: + raise ValueError("Guarded object-union branches require explicit array variant policy") + branches.append((model, model.policy.variant)) + for index, (_, left) in enumerate(branches): + for _, right in branches[index + 1 :]: + right_match = dict(right.match) + if not any(key in right_match and value != right_match[key] for key, value in left.match): + raise ValueError("Array variant discriminators must be disjoint") + selected = [(model, variant) for model, variant in branches if variant.cardinality != "any"] + if len(selected) != 1: + raise ValueError("Guarded arrays require exactly one bounded text variant") + for model, variant in branches: + if variant.cardinality == "any" and any( + field_policy(field).get("subject") + for nested in model_graph(model).values() + for field in nested.model_fields.values() + ): + raise ValueError("Unselected array variants must not hide guarded subjects") + return selected[0] + + def payload_contract( model: type[PolicyModel], *, @@ -474,12 +554,25 @@ def expand(node: dict[str, Any]) -> dict[str, Any]: if "anyOf" in result: alternatives = result.pop("anyOf") non_null = [branch for branch in alternatives if branch != {"type": "null"}] - if ( - len(alternatives) != 2 - or len(non_null) != 1 - or non_null[0].get("type") not in {"array", "object", "string", "boolean", "integer", "number"} - ): - raise ValueError("Contract export supports only disjoint nullable anyOf unions") + nullable = ( + len(alternatives) == 2 + and len(non_null) == 1 + and non_null[0].get("type") in {"array", "object", "string", "boolean", "integer", "number"} + ) + objects = all(branch.get("type") == "object" for branch in alternatives) + disjoint = objects and all( + any( + key in right.get("required", []) + and "const" in left.get("properties", {}).get(key, {}) + and "const" in right.get("properties", {}).get(key, {}) + and left["properties"][key]["const"] != right["properties"][key]["const"] + for key in left.get("required", []) + ) + for index, left in enumerate(alternatives) + for right in alternatives[index + 1 :] + ) + if not nullable and not disjoint: + raise ValueError("Contract export supports only disjoint nullable or required-literal object unions") result["oneOf"] = alternatives for keyword in ("anyOf", "oneOf", "allOf"): if keyword in result: @@ -513,8 +606,9 @@ def text_location(model: type[PolicyModel]) -> GuardedTextLocation: """Derive one buffered text target directly from typed field declarations. Follow guarded models and exactly-one-item lists without schema generation - or contract-format conversion. A nullable subject, or one without a minimum - length, may hold no text. Union selectors need explicit bindings. + or contract-format conversion. Heterogeneous arrays use ObjectPolicy.variant + to select exactly one text-bearing member. A nullable subject, or one without + a minimum length, may hold no text. Raises: ValueError: A subject is missing or ambiguous, a guarded container is @@ -534,11 +628,21 @@ def bound(metadata: list[Any], name: str, default: int | None = None) -> int | N return next((getattr(item, name) for item in reversed(metadata) if hasattr(item, name)), default) def visit_container( - annotation: Any, metadata: list[Any], path: tuple[str | int, ...], active: tuple[type[PolicyModel], ...] + annotation: Any, + metadata: list[Any], + path: tuple[str | int | GuardedArraySelection, ...], + active: tuple[type[PolicyModel], ...], ) -> None: """Follow only explicitly guarded model and singleton-list containers.""" annotation, metadata = unwrap(annotation, metadata) if get_origin(annotation) is list: + selected = guarded_array_variant(get_args(annotation)[0]) + if selected is not None: + item, variant = selected + if variant.cardinality != "exactly_one": + raise ValueError("Buffered array variants require exactly_one cardinality") + visit_model(item, (*path, GuardedArraySelection(variant.match, "exactly_one")), active) + return if bound(metadata, "min_length") != 1 or bound(metadata, "max_length") != 1: raise ValueError("Buffered text extraction requires exactly one array item") visit_container(get_args(annotation)[0], [], (*path, 0), active) @@ -550,7 +654,9 @@ def visit_container( ) def visit_model( - current: type[PolicyModel], path: tuple[str | int, ...], active: tuple[type[PolicyModel], ...] + current: type[PolicyModel], + path: tuple[str | int | GuardedArraySelection, ...], + active: tuple[type[PolicyModel], ...], ) -> None: """Collect subjects from the model objects that own their policies.""" if current in active: diff --git a/nemoguardrails/server/experimental/provider/stream_policy.py b/nemoguardrails/server/experimental/provider/stream_policy.py index 5822e3f48e..c6c9741351 100644 --- a/nemoguardrails/server/experimental/provider/stream_policy.py +++ b/nemoguardrails/server/experimental/provider/stream_policy.py @@ -28,11 +28,13 @@ PolicyModel, field_coverage, field_policy, + guarded_array_variant, model_graph, replacement_blocker, ) from nemoguardrails.server.experimental.provider.stream import StreamEventRole, StreamShapeCoverage from nemoguardrails.server.experimental.provider.stream_classifier import ( + StreamArraySelection, StreamClassifier, StreamClassifierDefinition, StreamEventRule, @@ -45,7 +47,8 @@ def stream_text_path(model: type[PolicyModel]) -> StreamTextPath: """Derive one named string or nullable-string subject from typed guarded fields. Follow guarded models, nullable model containers, and lists that allow at - most one item, without schema generation, so unrelated field types cannot + most one item, or heterogeneous arrays with an explicit ObjectPolicy.variant, + without schema generation, so unrelated field types cannot affect the binding. Unlike buffered extraction, empty arrays, null containers, and null text are valid: the event rule decides whether absent text is rejected or classified as metadata. This function only derives the @@ -55,8 +58,8 @@ def stream_text_path(model: type[PolicyModel]) -> StreamTextPath: ValueError: The declaration has no unique text subject, an array that may hold more than one item, an unsupported subject type, a union container, a recursive path, or a replacement blocker that is - not a reviewed sibling field. Object-union selectors and - multi-item arrays need an explicit StreamEventRule.text_path. + not a reviewed sibling field. Object-union selectors require + disjoint declared variants with one bounded text-bearing branch. """ paths: list[StreamTextPath] = [] @@ -82,6 +85,14 @@ def visit_container( """Follow guarded models and lists bounded to at most one item.""" annotation, metadata = unwrap(annotation, metadata) if get_origin(annotation) is list: + selected = guarded_array_variant(get_args(annotation)[0]) + if selected is not None: + item, variant = selected + cardinality = variant.cardinality + if cardinality == "any": + raise ValueError("Stream array variants require bounded cardinality") + visit_model(item, (*path, StreamArraySelection(variant.match, cardinality)), active) + return if max_length(metadata) != 1: raise ValueError("Stream text extraction requires at most one array item") visit_container(get_args(annotation)[0], [], (*path, 0), active) diff --git a/tests/server/experimental/test_projection_variants.py b/tests/server/experimental/test_projection_variants.py new file mode 100644 index 0000000000..99001b6f50 --- /dev/null +++ b/tests/server/experimental/test_projection_variants.py @@ -0,0 +1,246 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Keep heterogeneous text selection, runtime bindings, and export aligned.""" + +from dataclasses import replace +from typing import Annotated, ClassVar, Literal + +import pytest +from pydantic import ValidationError + +from nemoguardrails.server.experimental.provider.payload import GuardedArraySelection, GuardedPayloadModel +from nemoguardrails.server.experimental.provider.projection_policy import ( + EXTENSION, + ArrayVariant, + ObjectPolicy, + PolicyModel, + constrained, + export_payload_schema, + guarded, + payload_contract, + text_location, +) +from nemoguardrails.server.experimental.provider.stream_classifier import StreamArraySelection +from nemoguardrails.server.experimental.provider.stream_policy import stream_text_path + + +class MetadataItem(PolicyModel): + policy: ClassVar[ObjectPolicy] = ObjectPolicy( + variant=ArrayVariant(match=(("type", "metadata"),), cardinality="any"), opaque=("id",) + ) + type: Annotated[Literal["metadata"], constrained()] + + +class MessageItem(PolicyModel): + policy: ClassVar[ObjectPolicy] = ObjectPolicy( + variant=ArrayVariant(match=(("type", "message"),), cardinality="exactly_one") + ) + type: Annotated[Literal["message"], constrained()] + text: Annotated[str, guarded("assistant", replaceable=False, min_length=1)] + + +class Response(PolicyModel, GuardedPayloadModel): + output: Annotated[list[MetadataItem | MessageItem], guarded()] + + +Response.guarded_text_location = text_location(Response) +Response.projection_contract = payload_contract(Response, projection_id="example.response", direction="response") + + +@pytest.mark.parametrize( + "items", + [ + [{"type": "message", "text": "answer"}], + [{"type": "metadata", "id": "a"}, {"type": "message", "text": "answer"}], + [{"type": "message", "text": "answer"}, {"type": "metadata", "id": "b"}], + ], +) +def test_buffered_variant_binding_selects_original_text(items): + document = {"output": items} + projection = Response.validate_payload(document) + target = projection.locate_guarded_message(document) + assert target.message.content == "answer" + assert target.allows_replacement is False + assert Response.guarded_text_location.object_path == ( + "output", + GuardedArraySelection((("type", "message"),), "exactly_one"), + ) + + +@pytest.mark.parametrize( + "items", + [ + [], + [{"type": "metadata"}], + [{"type": "message", "text": "a"}, {"type": "message", "text": "b"}], + [{"type": "unknown", "text": "hidden"}], + ], +) +def test_buffered_variant_binding_rejects_missing_ambiguous_or_unreviewed_targets(items): + with pytest.raises(ValidationError): + Response.validate_payload({"output": items}) + + +def test_variant_export_preserves_disjoint_branches_and_cardinality(): + exported = export_payload_schema(Response, projection_id="example.response") + variants = exported["properties"]["output"]["items"]["oneOf"] + assert [entry[EXTENSION]["variant"] for entry in variants] == [ + {"match": {"type": "metadata"}, "cardinality": "any"}, + {"match": {"type": "message"}, "cardinality": "exactly_one"}, + ] + assert variants[1]["properties"]["text"][EXTENSION]["subject"]["role"] == "assistant" + + +def test_stream_variant_path_allows_initially_missing_message_but_not_two(): + class SnapshotMessage(MessageItem): + policy: ClassVar[ObjectPolicy] = replace( + MessageItem.policy, variant=ArrayVariant((("type", "message"),), "at_most_one") + ) + + class Snapshot(PolicyModel): + output: Annotated[list[MetadataItem | SnapshotMessage], guarded()] + + selector = StreamArraySelection((("type", "message"),), "at_most_one") + assert stream_text_path(Snapshot) == ("output", selector, "text") + assert selector.select([]) is None + with pytest.raises(ValueError): + selector.select([SnapshotMessage(type="message", text="a"), SnapshotMessage(type="message", text="b")]) + with pytest.raises(ValueError, match="exactly_one"): + text_location(Snapshot) + + +@pytest.mark.parametrize("match", [(), (("type", "a"), ("type", "b")), (("type", None),)]) +def test_variant_match_requires_unique_named_scalar_discriminators(match): + with pytest.raises(ValueError): + ArrayVariant(match, "exactly_one") + + +@pytest.mark.parametrize("annotation,default", [(str, ...), (Literal["other"], ...), (Literal["message"], "message")]) +def test_variant_discriminators_must_be_required_matching_literals(annotation, default): + with pytest.raises(ValueError, match="required.*Literal"): + + class Invalid(PolicyModel): + policy: ClassVar[ObjectPolicy] = ObjectPolicy(variant=ArrayVariant((("type", "message"),), "exactly_one")) + type: Annotated[annotation, constrained()] = default + + +def test_export_still_rejects_overlapping_object_unions(): + class Overlap(PolicyModel): + type: Annotated[Literal["message"], constrained()] + text: Annotated[str, guarded("assistant")] + + class Invalid(PolicyModel): + output: Annotated[list[MessageItem | Overlap], guarded()] + + with pytest.raises(ValueError, match="disjoint"): + export_payload_schema(Invalid, projection_id="invalid") + with pytest.raises(ValueError, match="variant"): + text_location(Invalid) + + +def test_complete_endpoint_export_checks_variant_bindings(): + import json + from pathlib import Path + + from jsonschema import Draft202012Validator + from pydantic import StrictBool + + from nemoguardrails.server.experimental.provider.contract_export import export_guard_contract + from nemoguardrails.server.experimental.provider.endpoint import GuardedJsonEndpoint + from nemoguardrails.server.experimental.provider.payload import GuardedRequestModel + from nemoguardrails.server.experimental.provider.stream import ( + StreamBinding, + StreamCapabilityProfile, + StreamEventRole, + StreamProjectionContract, + ) + from nemoguardrails.server.experimental.provider.stream_classifier import ( + StreamClassifierDefinition, + StreamEventRule, + ) + from nemoguardrails.server.experimental.provider.stream_policy import ( + build_policy_stream_classifier, + stream_shape_coverage, + ) + + class Request(PolicyModel, GuardedRequestModel): + input: Annotated[str, guarded("user")] + stream: Annotated[StrictBool, constrained()] = False + stream_selector_field = "stream" + + Request.guarded_text_location = text_location(Request) + Request.projection_contract = payload_contract(Request, projection_id="example.request", direction="request") + + class Snapshot(PolicyModel): + policy: ClassVar[ObjectPolicy] = ObjectPolicy(source="ExampleSnapshot") + output: Annotated[list[MetadataItem | MessageItem], guarded()] + + rules = ( + StreamEventRule( + shape="snapshot", + model=Snapshot, + role=StreamEventRole.TEXT_SNAPSHOT, + required_fields=frozenset({"output"}), + text_path=stream_text_path(Snapshot), + ), + ) + classifier = build_policy_stream_classifier( + StreamClassifierDefinition( + subject="example.stream", + contract=StreamProjectionContract( + "example.stream", StreamCapabilityProfile.SINGLE_TEXT_DELTA_V1, stream_shape_coverage(rules) + ), + rules=rules, + ) + ) + + def hooks(): + raise AssertionError("Export must not construct runtime hooks") + + endpoint = GuardedJsonEndpoint( + provider_operation_id="createExample", + route_path="/example", + operation_name="example.create", + operation="Example", + unsupported_request_code="unsupported_request", + unsupported_response_code="unsupported_response", + guarded_request_model=Request, + guarded_response_model=Response, + stream=StreamBinding(classifier, hooks), + ) + exported = export_guard_contract(endpoint) + schema_path = Path(__file__).parents[3] / "nemoguardrails/server/experimental/contracts/guard-contract.schema.json" + Draft202012Validator(json.loads(schema_path.read_text())).validate(exported) + assert ( + exported["stream"]["oneOf"][0]["properties"]["output"]["items"]["oneOf"][1][EXTENSION]["variant"]["cardinality"] + == "exactly_one" + ) + wrong_rule = replace(rules[0], text_path=("output", 0, "text")) + wrong_classifier = replace(classifier, rules=(wrong_rule,)) + with pytest.raises(ValueError, match="text binding differs"): + export_guard_contract(replace(endpoint, stream=StreamBinding(wrong_classifier, hooks))) + + +def test_unselected_variants_cannot_hide_text(): + class Hidden(MetadataItem): + text: Annotated[str, guarded("assistant")] + + class Invalid(PolicyModel): + output: Annotated[list[Hidden | MessageItem], guarded()] + + for derive in (text_location, stream_text_path): + with pytest.raises(ValueError, match="hide guarded"): + derive(Invalid) From 358efb1398c2b13dd0d3871455da259aa19b1521 Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:27:04 +0200 Subject: [PATCH 02/11] feat(server): declare buffered Responses policy in typed Python Bind a single foreground input and one assistant output within heterogeneous provider output. Preserve reviewed metadata while closing unsupported content paths, including citations and token logprobs. Export the actual endpoint as a derived contract and check variant cardinality against runtime validation without introducing compiler dependencies. Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- .../openai/_generated/responses.guard.yaml | 522 ++++++++++++++++++ .../providers/openai/responses/__init__.py | 16 + .../providers/openai/responses/endpoint.py | 38 ++ .../openai/responses/request_binding.py | 31 ++ .../openai/responses/request_projection.py | 102 ++++ .../openai/responses/response_binding.py | 30 + .../openai/responses/response_projection.py | 114 ++++ tests/server/experimental/conftest.py | 20 + .../test_openai_responses_contract_export.py | 70 +++ .../test_openai_responses_projections.py | 171 ++++++ 10 files changed, 1114 insertions(+) create mode 100644 nemoguardrails/server/experimental/contracts/openai/_generated/responses.guard.yaml create mode 100644 nemoguardrails/server/experimental/providers/openai/responses/__init__.py create mode 100644 nemoguardrails/server/experimental/providers/openai/responses/endpoint.py create mode 100644 nemoguardrails/server/experimental/providers/openai/responses/request_binding.py create mode 100644 nemoguardrails/server/experimental/providers/openai/responses/request_projection.py create mode 100644 nemoguardrails/server/experimental/providers/openai/responses/response_binding.py create mode 100644 nemoguardrails/server/experimental/providers/openai/responses/response_projection.py create mode 100644 tests/server/experimental/test_openai_responses_contract_export.py create mode 100644 tests/server/experimental/test_openai_responses_projections.py diff --git a/nemoguardrails/server/experimental/contracts/openai/_generated/responses.guard.yaml b/nemoguardrails/server/experimental/contracts/openai/_generated/responses.guard.yaml new file mode 100644 index 0000000000..acbfeb00e6 --- /dev/null +++ b/nemoguardrails/server/experimental/contracts/openai/_generated/responses.guard.yaml @@ -0,0 +1,522 @@ +version: 1.0.0-alpha.1 +operationId: createResponse +profile: single_text.v1 +request: + additionalProperties: false + description: Bind the declared input subject and strict response-mode selector. + properties: + background: + const: false + default: false + type: boolean + x-nemo-guardrails: + classification: constrained + reason: core_capability.background_execution + context_management: + default: null + type: 'null' + x-nemo-guardrails: + classification: constrained + gate: disabled + reason: core_capability.conversation_state + conversation: + default: null + type: 'null' + x-nemo-guardrails: + classification: constrained + gate: disabled + reason: core_capability.conversation_state + include: + default: null + type: 'null' + x-nemo-guardrails: + classification: constrained + gate: disabled + reason: core_capability.additional_content + input: + minLength: 1 + type: string + x-nemo-guardrails: + classification: guarded + subject: + kind: text + replaceable: false + role: user + instructions: + default: null + type: 'null' + x-nemo-guardrails: + classification: constrained + gate: disabled + reason: core_capability.additional_input + max_output_tokens: + x-nemo-guardrails: + classification: opaque + max_tool_calls: + x-nemo-guardrails: + classification: opaque + metadata: + x-nemo-guardrails: + classification: opaque + model: + x-nemo-guardrails: + classification: opaque + moderation: + x-nemo-guardrails: + classification: opaque + parallel_tool_calls: + x-nemo-guardrails: + classification: opaque + previous_response_id: + default: null + type: 'null' + x-nemo-guardrails: + classification: constrained + gate: disabled + reason: core_capability.conversation_state + prompt: + default: null + type: 'null' + x-nemo-guardrails: + classification: constrained + gate: disabled + reason: core_capability.additional_input + prompt_cache_key: + x-nemo-guardrails: + classification: opaque + prompt_cache_options: + x-nemo-guardrails: + classification: opaque + prompt_cache_retention: + x-nemo-guardrails: + classification: opaque + reasoning: + default: null + x-nemo-guardrails: + classification: constrained + oneOf: + - additionalProperties: false + description: Allow reviewed reasoning controls, but no separate reasoning + text input. + properties: + context: + default: null + type: 'null' + x-nemo-guardrails: + classification: constrained + gate: disabled + reason: core_capability.reasoning_content + effort: + x-nemo-guardrails: + classification: opaque + generate_summary: + default: null + type: 'null' + x-nemo-guardrails: + classification: constrained + gate: disabled + reason: core_capability.reasoning_content + mode: + default: null + type: 'null' + x-nemo-guardrails: + classification: constrained + gate: disabled + reason: core_capability.reasoning_content + summary: + default: null + type: 'null' + x-nemo-guardrails: + classification: constrained + gate: disabled + reason: core_capability.reasoning_content + title: ResponsesReasoningConfigProjection + type: object + x-nemo-guardrails: + source: '#/components/schemas/Reasoning' + unknown_fields: configurable + reject_case_aliases: true + - type: 'null' + safety_identifier: + x-nemo-guardrails: + classification: opaque + service_tier: + x-nemo-guardrails: + classification: opaque + store: + x-nemo-guardrails: + classification: opaque + stream: + default: false + type: boolean + x-nemo-guardrails: + classification: constrained + stream_options: + x-nemo-guardrails: + classification: opaque + temperature: + x-nemo-guardrails: + classification: opaque + text: + default: null + x-nemo-guardrails: + classification: constrained + oneOf: + - additionalProperties: false + description: Retain verbosity while restricting any supplied format to plain + text. + properties: + format: + default: null + x-nemo-guardrails: + classification: constrained + oneOf: + - additionalProperties: false + description: Disallow structured-output instructions outside the guarded + input. + properties: + type: + const: text + type: string + x-nemo-guardrails: + classification: constrained + reason: projection_policy.plain_text_output + required: + - type + title: ResponsesTextFormatProjection + type: object + x-nemo-guardrails: + source: '#/components/schemas/ResponseFormatText' + unknown_fields: configurable + reject_case_aliases: true + - type: 'null' + verbosity: + x-nemo-guardrails: + classification: opaque + title: ResponsesTextConfigProjection + type: object + x-nemo-guardrails: + source: '#/components/schemas/ResponseTextParam' + unknown_fields: configurable + reject_case_aliases: true + - type: 'null' + tool_choice: + default: null + type: 'null' + x-nemo-guardrails: + classification: constrained + gate: disabled + reason: core_capability.tool_content + tools: + default: null + type: 'null' + x-nemo-guardrails: + classification: constrained + gate: disabled + reason: core_capability.tool_content + top_logprobs: + default: null + x-nemo-guardrails: + classification: constrained + reason: provider_integrity.token_logprobs + oneOf: + - maximum: 0 + minimum: 0 + type: integer + - type: 'null' + top_p: + x-nemo-guardrails: + classification: opaque + truncation: + x-nemo-guardrails: + classification: opaque + user: + x-nemo-guardrails: + classification: opaque + required: + - input + title: openai.responses.request.text.v1 + type: object + x-nemo-guardrails: + source: '#/components/schemas/CreateResponse' + unknown_fields: forbid + reject_case_aliases: true + model: ResponsesGuardedRequest + stream_selector_field: stream +response: + additionalProperties: false + description: Bind one assistant text target; replacement is deliberately unsupported. + properties: + background: + x-nemo-guardrails: + classification: opaque + completed_at: + x-nemo-guardrails: + classification: opaque + conversation: + x-nemo-guardrails: + classification: opaque + created_at: + x-nemo-guardrails: + classification: opaque + error: + x-nemo-guardrails: + classification: opaque + id: + x-nemo-guardrails: + classification: opaque + incomplete_details: + x-nemo-guardrails: + classification: opaque + instructions: + x-nemo-guardrails: + classification: opaque + max_output_tokens: + x-nemo-guardrails: + classification: opaque + max_tool_calls: + x-nemo-guardrails: + classification: opaque + metadata: + x-nemo-guardrails: + classification: opaque + model: + x-nemo-guardrails: + classification: opaque + moderation: + x-nemo-guardrails: + classification: opaque + object: + x-nemo-guardrails: + classification: opaque + output: + items: + oneOf: + - additionalProperties: false + description: Preserve opaque reasoning metadata, not separate plaintext + reasoning. + properties: + content: + default: null + x-nemo-guardrails: + classification: constrained + reason: core_capability.reasoning_content + oneOf: + - items: {} + maxItems: 0 + type: array + - type: 'null' + encrypted_content: + x-nemo-guardrails: + classification: opaque + id: + x-nemo-guardrails: + classification: opaque + status: + x-nemo-guardrails: + classification: opaque + summary: + default: null + x-nemo-guardrails: + classification: constrained + reason: core_capability.reasoning_content + oneOf: + - items: {} + maxItems: 0 + type: array + - type: 'null' + type: + const: reasoning + type: string + x-nemo-guardrails: + classification: constrained + required: + - type + title: ResponsesReasoningItemProjection + type: object + x-nemo-guardrails: + variant: + cardinality: any + match: + type: reasoning + source: '#/components/schemas/ReasoningItem' + unknown_fields: configurable + reject_case_aliases: true + - additionalProperties: false + description: Select exactly one assistant message without assuming its output + index. + properties: + content: + items: + additionalProperties: false + description: Guard output text and reject uninspected citation and + token text. + properties: + annotations: + default: null + x-nemo-guardrails: + classification: constrained + reason: core_capability.citation_content + oneOf: + - items: {} + maxItems: 0 + type: array + - type: 'null' + logprobs: + default: null + x-nemo-guardrails: + classification: constrained + reason: provider_integrity.token_logprobs + oneOf: + - items: {} + maxItems: 0 + type: array + - type: 'null' + text: + minLength: 1 + type: string + x-nemo-guardrails: + classification: guarded + subject: + kind: text + replaceable: false + role: assistant + type: + const: output_text + type: string + x-nemo-guardrails: + classification: constrained + required: + - type + - text + title: ResponsesOutputTextProjection + type: object + x-nemo-guardrails: + source: '#/components/schemas/OutputTextContent' + unknown_fields: configurable + reject_case_aliases: true + maxItems: 1 + minItems: 1 + type: array + x-nemo-guardrails: + classification: guarded + id: + x-nemo-guardrails: + classification: opaque + phase: + x-nemo-guardrails: + classification: opaque + role: + const: assistant + type: string + x-nemo-guardrails: + classification: constrained + status: + x-nemo-guardrails: + classification: opaque + type: + const: message + type: string + x-nemo-guardrails: + classification: constrained + required: + - type + - role + - content + title: ResponsesAssistantMessageProjection + type: object + x-nemo-guardrails: + variant: + cardinality: exactly_one + match: + role: assistant + type: message + source: '#/components/schemas/OutputMessage' + unknown_fields: configurable + reject_case_aliases: true + type: array + x-nemo-guardrails: + classification: guarded + output_text: + default: null + type: 'null' + x-nemo-guardrails: + classification: constrained + gate: disabled + reason: provider_integrity.duplicate_text + parallel_tool_calls: + x-nemo-guardrails: + classification: opaque + previous_response_id: + x-nemo-guardrails: + classification: opaque + prompt: + x-nemo-guardrails: + classification: opaque + prompt_cache_diagnostics: + x-nemo-guardrails: + classification: opaque + prompt_cache_key: + x-nemo-guardrails: + classification: opaque + prompt_cache_options: + x-nemo-guardrails: + classification: opaque + prompt_cache_retention: + x-nemo-guardrails: + classification: opaque + reasoning: + x-nemo-guardrails: + classification: opaque + safety_identifier: + x-nemo-guardrails: + classification: opaque + service_tier: + x-nemo-guardrails: + classification: opaque + status: + x-nemo-guardrails: + classification: opaque + temperature: + x-nemo-guardrails: + classification: opaque + text: + x-nemo-guardrails: + classification: opaque + tool_choice: + x-nemo-guardrails: + classification: opaque + tools: + x-nemo-guardrails: + classification: opaque + top_logprobs: + x-nemo-guardrails: + classification: opaque + top_p: + x-nemo-guardrails: + classification: opaque + truncation: + x-nemo-guardrails: + classification: opaque + usage: + x-nemo-guardrails: + classification: opaque + user: + x-nemo-guardrails: + classification: opaque + required: + - output + title: openai.responses.response.text.v1 + type: object + x-nemo-guardrails: + source: '#/components/schemas/Response' + unknown_fields: forbid + reject_case_aliases: true + model: ResponsesGuardedResponse +integration: + name: responses + endpoint: + route_path: /v1/responses + operation_label: OpenAI Responses + unsupported_request_code: unsupported_responses_shape + unsupported_response_code: unsupported_responses_response_shape diff --git a/nemoguardrails/server/experimental/providers/openai/responses/__init__.py b/nemoguardrails/server/experimental/providers/openai/responses/__init__.py new file mode 100644 index 0000000000..8201fe9ead --- /dev/null +++ b/nemoguardrails/server/experimental/providers/openai/responses/__init__.py @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Typed policy and protocol bindings for foreground OpenAI Responses.""" diff --git a/nemoguardrails/server/experimental/providers/openai/responses/endpoint.py b/nemoguardrails/server/experimental/providers/openai/responses/endpoint.py new file mode 100644 index 0000000000..59540bb2d5 --- /dev/null +++ b/nemoguardrails/server/experimental/providers/openai/responses/endpoint.py @@ -0,0 +1,38 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Compose the guarded Responses operation from its typed bindings.""" + +from nemoguardrails.server.experimental._http_paths import GuardedOperationPath +from nemoguardrails.server.experimental.provider.endpoint import GuardedJsonEndpoint +from nemoguardrails.server.experimental.providers.openai.responses.request_binding import ResponsesGuardedRequest +from nemoguardrails.server.experimental.providers.openai.responses.response_binding import ResponsesGuardedResponse + +RESPONSES_ENDPOINT = GuardedJsonEndpoint( + provider_operation_id="createResponse", + contract_name="responses", + route_path="/v1/responses", + operation_name="responses.create", + operation="OpenAI Responses", + unsupported_request_code="unsupported_responses_shape", + unsupported_response_code="unsupported_responses_response_shape", + guarded_request_model=ResponsesGuardedRequest, + guarded_response_model=ResponsesGuardedResponse, + method="POST", + operation_paths=( + GuardedOperationPath("/v1/responses", frozenset({"POST"})), + GuardedOperationPath("/{api_version}/responses", frozenset({"POST"})), + ), +) diff --git a/nemoguardrails/server/experimental/providers/openai/responses/request_binding.py b/nemoguardrails/server/experimental/providers/openai/responses/request_binding.py new file mode 100644 index 0000000000..c19bf44b6b --- /dev/null +++ b/nemoguardrails/server/experimental/providers/openai/responses/request_binding.py @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Derive request coverage and text extraction from Responses field policies.""" + +from nemoguardrails.server.experimental.provider.projection_policy import payload_contract, text_location +from nemoguardrails.server.experimental.providers.openai.responses.request_projection import ( + ResponsesGuardedRequestProjection, +) + + +class ResponsesGuardedRequest(ResponsesGuardedRequestProjection): + """Bind the declared input subject and strict response-mode selector.""" + + guarded_text_location = text_location(ResponsesGuardedRequestProjection) + projection_contract = payload_contract( + ResponsesGuardedRequestProjection, projection_id="openai.responses.request.text.v1", direction="request" + ) + stream_selector_field = "stream" diff --git a/nemoguardrails/server/experimental/providers/openai/responses/request_projection.py b/nemoguardrails/server/experimental/providers/openai/responses/request_projection.py new file mode 100644 index 0000000000..b9801c7801 --- /dev/null +++ b/nemoguardrails/server/experimental/providers/openai/responses/request_projection.py @@ -0,0 +1,102 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Declare the single-text, foreground Responses request boundary.""" + +from typing import Annotated, ClassVar, Literal + +from pydantic import StrictBool + +from nemoguardrails.server.experimental.provider.payload import GuardedContentModel, GuardedRequestModel, StrictFalse +from nemoguardrails.server.experimental.provider.projection_policy import ( + ObjectPolicy, + PolicyModel, + constrained, + disabled, + guarded, +) + + +class ResponsesReasoningConfigProjection(PolicyModel, GuardedContentModel): + """Allow reviewed reasoning controls, but no separate reasoning text input.""" + + policy: ClassVar[ObjectPolicy] = ObjectPolicy(source="Reasoning", opaque=("effort",), unknown_fields="configurable") + context: Annotated[None, disabled("core_capability.reasoning_content")] = None + generate_summary: Annotated[None, disabled("core_capability.reasoning_content")] = None + mode: Annotated[None, disabled("core_capability.reasoning_content")] = None + summary: Annotated[None, disabled("core_capability.reasoning_content")] = None + + +class ResponsesTextFormatProjection(PolicyModel, GuardedContentModel): + """Disallow structured-output instructions outside the guarded input.""" + + policy: ClassVar[ObjectPolicy] = ObjectPolicy(source="ResponseFormatText", unknown_fields="configurable") + type: Annotated[Literal["text"], constrained(reason="projection_policy.plain_text_output")] + + +class ResponsesTextConfigProjection(PolicyModel, GuardedContentModel): + """Retain verbosity while restricting any supplied format to plain text.""" + + policy: ClassVar[ObjectPolicy] = ObjectPolicy( + source="ResponseTextParam", opaque=("verbosity",), unknown_fields="configurable" + ) + format: Annotated[ResponsesTextFormatProjection | None, constrained()] = None + + +class ResponsesGuardedRequestProjection(PolicyModel, GuardedRequestModel): + """Inspect one string input without provider-managed history or tools. + + Provider controls are reviewed passthrough values, not inspected subjects. + Unknown root fields fail closed; no backend dialect is inferred here. + """ + + policy: ClassVar[ObjectPolicy] = ObjectPolicy( + source="CreateResponse", + opaque=( + "max_output_tokens", + "max_tool_calls", + "metadata", + "model", + "moderation", + "parallel_tool_calls", + "prompt_cache_key", + "prompt_cache_options", + "prompt_cache_retention", + "safety_identifier", + "service_tier", + "store", + "stream_options", + "temperature", + "top_p", + "truncation", + "user", + ), + ) + input: Annotated[str, guarded("user", replaceable=False, min_length=1)] + stream: Annotated[StrictBool, constrained()] = False + background: Annotated[StrictFalse, constrained(reason="core_capability.background_execution")] = False + context_management: Annotated[None, disabled("core_capability.conversation_state")] = None + conversation: Annotated[None, disabled("core_capability.conversation_state")] = None + include: Annotated[None, disabled("core_capability.additional_content")] = None + instructions: Annotated[None, disabled("core_capability.additional_input")] = None + previous_response_id: Annotated[None, disabled("core_capability.conversation_state")] = None + prompt: Annotated[None, disabled("core_capability.additional_input")] = None + reasoning: Annotated[ResponsesReasoningConfigProjection | None, constrained()] = None + text: Annotated[ResponsesTextConfigProjection | None, constrained()] = None + tool_choice: Annotated[None, disabled("core_capability.tool_content")] = None + tools: Annotated[None, disabled("core_capability.tool_content")] = None + top_logprobs: Annotated[ + int | None, constrained(reason="provider_integrity.token_logprobs", strict=True, ge=0, le=0) + ] = None diff --git a/nemoguardrails/server/experimental/providers/openai/responses/response_binding.py b/nemoguardrails/server/experimental/providers/openai/responses/response_binding.py new file mode 100644 index 0000000000..8ad22ba9cc --- /dev/null +++ b/nemoguardrails/server/experimental/providers/openai/responses/response_binding.py @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Derive buffered Responses coverage and heterogeneous text selection.""" + +from nemoguardrails.server.experimental.provider.projection_policy import payload_contract, text_location +from nemoguardrails.server.experimental.providers.openai.responses.response_projection import ( + ResponsesGuardedResponseProjection, +) + + +class ResponsesGuardedResponse(ResponsesGuardedResponseProjection): + """Bind one assistant text target; replacement is deliberately unsupported.""" + + guarded_text_location = text_location(ResponsesGuardedResponseProjection) + projection_contract = payload_contract( + ResponsesGuardedResponseProjection, projection_id="openai.responses.response.text.v1", direction="response" + ) diff --git a/nemoguardrails/server/experimental/providers/openai/responses/response_projection.py b/nemoguardrails/server/experimental/providers/openai/responses/response_projection.py new file mode 100644 index 0000000000..293ad0d476 --- /dev/null +++ b/nemoguardrails/server/experimental/providers/openai/responses/response_projection.py @@ -0,0 +1,114 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Declare one assistant text output alongside reviewed reasoning metadata.""" + +from typing import Annotated, Any, ClassVar, Literal + +from nemoguardrails.server.experimental.provider.payload import GuardedContentModel, GuardedPayloadModel +from nemoguardrails.server.experimental.provider.projection_policy import ( + ArrayVariant, + ObjectPolicy, + PolicyModel, + constrained, + disabled, + guarded, +) + + +class ResponsesOutputTextProjection(PolicyModel, GuardedContentModel): + """Guard output text and reject uninspected citation and token text.""" + + policy: ClassVar[ObjectPolicy] = ObjectPolicy(source="OutputTextContent", unknown_fields="configurable") + type: Annotated[Literal["output_text"], constrained()] + text: Annotated[str, guarded("assistant", replaceable=False, min_length=1)] + annotations: Annotated[list[Any] | None, constrained(reason="core_capability.citation_content", max_length=0)] = ( + None + ) + logprobs: Annotated[list[Any] | None, constrained(reason="provider_integrity.token_logprobs", max_length=0)] = None + + +class ResponsesReasoningItemProjection(PolicyModel, GuardedContentModel): + """Preserve opaque reasoning metadata, not separate plaintext reasoning.""" + + policy: ClassVar[ObjectPolicy] = ObjectPolicy( + source="ReasoningItem", + opaque=("encrypted_content", "id", "status"), + unknown_fields="configurable", + variant=ArrayVariant((("type", "reasoning"),), "any"), + ) + type: Annotated[Literal["reasoning"], constrained()] + content: Annotated[list[Any] | None, constrained(reason="core_capability.reasoning_content", max_length=0)] = None + summary: Annotated[list[Any] | None, constrained(reason="core_capability.reasoning_content", max_length=0)] = None + + +class ResponsesAssistantMessageProjection(PolicyModel, GuardedContentModel): + """Select exactly one assistant message without assuming its output index.""" + + policy: ClassVar[ObjectPolicy] = ObjectPolicy( + source="OutputMessage", + opaque=("id", "phase", "status"), + unknown_fields="configurable", + variant=ArrayVariant((("type", "message"), ("role", "assistant")), "exactly_one"), + ) + type: Annotated[Literal["message"], constrained()] + role: Annotated[Literal["assistant"], constrained()] + content: Annotated[list[ResponsesOutputTextProjection], guarded(min_length=1, max_length=1)] + + +RESPONSE_METADATA_FIELDS = ( + "background", + "completed_at", + "conversation", + "created_at", + "error", + "id", + "incomplete_details", + "instructions", + "max_output_tokens", + "max_tool_calls", + "metadata", + "model", + "moderation", + "object", + "parallel_tool_calls", + "previous_response_id", + "prompt", + "prompt_cache_diagnostics", + "prompt_cache_key", + "prompt_cache_options", + "prompt_cache_retention", + "reasoning", + "safety_identifier", + "service_tier", + "status", + "temperature", + "text", + "tool_choice", + "tools", + "top_logprobs", + "top_p", + "truncation", + "usage", + "user", +) + + +class ResponsesGuardedResponseProjection(PolicyModel, GuardedPayloadModel): + """Keep provider output ordering and select one reviewed text-bearing item.""" + + policy: ClassVar[ObjectPolicy] = ObjectPolicy(source="Response", opaque=RESPONSE_METADATA_FIELDS) + output: Annotated[list[ResponsesReasoningItemProjection | ResponsesAssistantMessageProjection], guarded()] + output_text: Annotated[None, disabled("provider_integrity.duplicate_text")] = None diff --git a/tests/server/experimental/conftest.py b/tests/server/experimental/conftest.py index 16132b7bea..08308b3cc5 100644 --- a/tests/server/experimental/conftest.py +++ b/tests/server/experimental/conftest.py @@ -53,6 +53,26 @@ def _lower_export(node: object, policy: UnknownContentFieldPolicy) -> object: if node.get(EXTENSION, {}).get("reject_case_aliases") is True: lowered["x-test-reviewed-properties"] = list(node["properties"]) lowered["properties"] = {name: _lower_export(child, policy) for name, child in node["properties"].items()} + if node.get("type") == "array": + bounds = [] + for branch in node.get("items", {}).get("oneOf", []): + variant = branch.get(EXTENSION, {}).get("variant", {}) + cardinality = variant.get("cardinality") + if cardinality in {"exactly_one", "at_most_one"}: + match = variant["match"] + bounds.append( + { + "contains": { + "type": "object", + "properties": {key: {"const": value} for key, value in match.items()}, + "required": list(match), + }, + "minContains": 1 if cardinality == "exactly_one" else 0, + "maxContains": 1, + } + ) + if bounds: + lowered["allOf"] = [*lowered.get("allOf", []), *bounds] return lowered diff --git a/tests/server/experimental/test_openai_responses_contract_export.py b/tests/server/experimental/test_openai_responses_contract_export.py new file mode 100644 index 0000000000..466a950be8 --- /dev/null +++ b/tests/server/experimental/test_openai_responses_contract_export.py @@ -0,0 +1,70 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Verify that Responses documentation describes the actual typed bindings.""" + +import json +from pathlib import Path + +import pytest +import yaml +from jsonschema import Draft202012Validator +from pydantic import ValidationError + +from nemoguardrails.server.experimental.provider.contract_export import export_guard_contract +from nemoguardrails.server.experimental.provider.projection_policy import EXTENSION +from nemoguardrails.server.experimental.provider.types import UnknownContentFieldPolicy +from nemoguardrails.server.experimental.providers.openai.responses.endpoint import RESPONSES_ENDPOINT +from tests.server.experimental.test_openai_responses_projections import output_message, response_payload + +CONTRACTS = Path(__file__).parents[3] / "nemoguardrails/server/experimental/contracts" + + +def test_responses_export_matches_checked_in_artifact_and_format(): + exported = export_guard_contract(RESPONSES_ENDPOINT) + assert exported == yaml.safe_load((CONTRACTS / "openai/_generated/responses.guard.yaml").read_text()) + Draft202012Validator(json.loads((CONTRACTS / "guard-contract.schema.json").read_text())).validate(exported) + assert exported["operationId"] == "createResponse" + assert exported["integration"]["endpoint"]["route_path"] == "/v1/responses" + + +@pytest.mark.parametrize( + "output", + [ + response_payload()["output"], + list(reversed(response_payload()["output"])), + [], + [{"type": "reasoning"}], + [output_message(), output_message()], + [output_message(), {"type": "function_call", "arguments": "hidden"}], + ], +) +def test_exported_variant_cardinality_matches_bound_validation(output, export_policy_validator): + model = RESPONSES_ENDPOINT.guarded_response_model + document = {"output": output} + validator = export_policy_validator( + export_guard_contract(RESPONSES_ENDPOINT)["response"], UnknownContentFieldPolicy.FORBID + ) + if validator.is_valid(document): + model.validate_payload(document) + else: + with pytest.raises(ValidationError): + model.validate_payload(document) + + +def test_export_marks_reasoning_as_unselected_and_assistant_as_exactly_one(): + variants = export_guard_contract(RESPONSES_ENDPOINT)["response"]["properties"]["output"]["items"]["oneOf"] + assert [branch[EXTENSION]["variant"]["cardinality"] for branch in variants] == ["any", "exactly_one"] + assert variants[1][EXTENSION]["variant"]["match"] == {"type": "message", "role": "assistant"} diff --git a/tests/server/experimental/test_openai_responses_projections.py b/tests/server/experimental/test_openai_responses_projections.py new file mode 100644 index 0000000000..8294e64e8c --- /dev/null +++ b/tests/server/experimental/test_openai_responses_projections.py @@ -0,0 +1,171 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Characterize the typed foreground Responses boundary without provider calls.""" + +from copy import deepcopy + +import pytest +from pydantic import ValidationError + +from nemoguardrails.server.experimental.provider.types import UnknownContentFieldPolicy +from nemoguardrails.server.experimental.providers.openai.responses.request_binding import ResponsesGuardedRequest +from nemoguardrails.server.experimental.providers.openai.responses.response_binding import ResponsesGuardedResponse + + +def output_message(text="answer", **updates): + """Build a synthetic single-text message, not a recorded provider response.""" + return { + "type": "message", + "role": "assistant", + "id": "msg_test", + "content": [{"type": "output_text", "text": text, "annotations": [], "logprobs": []}], + **updates, + } + + +def response_payload(text="answer"): + """Include reasoning metadata before the assistant item to exercise selection.""" + return { + "id": "resp_test", + "object": "response", + "output": [ + {"type": "reasoning", "id": "rs_test", "summary": [], "content": [], "encrypted_content": "opaque"}, + output_message(text), + ], + "usage": {"output_tokens": 1}, + } + + +def test_request_extracts_original_input_without_modifying_provider_fields(): + payload = { + "model": "test-model", + "input": "hello", + "reasoning": {"effort": "medium"}, + "text": {"format": {"type": "text"}, "verbosity": "medium"}, + } + original = deepcopy(payload) + request = ResponsesGuardedRequest.validate_payload(payload) + target = request.locate_guarded_message(payload) + assert target.message.content == "hello" + assert request.streams_response is False + assert not target.allows_replacement + assert payload == original + + +@pytest.mark.parametrize( + "updates", + [ + {"input": ""}, + {"input": [{"role": "user", "content": "hidden"}]}, + {"instructions": "hidden"}, + {"previous_response_id": "prior"}, + {"conversation": "prior"}, + {"tools": []}, + {"tool_choice": "none"}, + {"include": []}, + {"background": True}, + {"background": 0}, + {"prompt": {}}, + {"context_management": []}, + {"top_logprobs": 1}, + {"top_logprobs": True}, + {"stream": "true"}, + {"stream": 1}, + {"stream": None}, + {"future_prompt": "hidden"}, + {"Input": "hidden"}, + {"reasoning": {"summary": "auto"}}, + {"text": {"format": {"type": "json_schema", "schema": {}}}}, + ], +) +def test_unsupported_requests_fail_closed(updates): + with pytest.raises(ValidationError): + ResponsesGuardedRequest.validate_payload({"input": "hello", **updates}) + + +@pytest.mark.parametrize("stream", [False, True]) +def test_request_selects_response_mode_from_a_strict_boolean(stream): + request = ResponsesGuardedRequest.validate_payload({"input": "hello", "stream": stream, "top_logprobs": 0}) + assert request.streams_response is stream + + +def test_buffered_response_finds_the_only_message_and_preserves_metadata(): + payload = response_payload() + original = deepcopy(payload) + projection = ResponsesGuardedResponse.validate_payload(payload) + target = projection.locate_guarded_message(payload) + assert target.message.content == "answer" + assert not target.allows_replacement + with pytest.raises(ValueError, match="replacement"): + target.replace_content("changed") + assert payload == original + payload["output"].reverse() + assert ( + ResponsesGuardedResponse.validate_payload(payload).locate_guarded_message(payload).message.content == "answer" + ) + + +@pytest.mark.parametrize( + "output", + [ + [], + [{"type": "reasoning", "summary": []}], + [output_message(), output_message()], + [output_message(role="user")], + [output_message(content=[])], + [output_message(content=[{"type": "refusal", "refusal": "hidden"}])], + [output_message(), {"type": "function_call", "arguments": "hidden"}], + [output_message(), {"type": "reasoning", "summary": [{"type": "summary_text", "text": "hidden"}]}], + ], +) +def test_response_rejects_missing_ambiguous_or_unsupported_content(output): + with pytest.raises(ValidationError): + ResponsesGuardedResponse.validate_payload({"output": output}) + + +@pytest.mark.parametrize( + "field,value", + [ + ("annotations", [{"title": "hidden"}]), + ("logprobs", [{"token": "hidden"}]), + ("text", ""), + ("future_text", "hidden"), + ("Text", "hidden"), + ], +) +def test_nested_response_text_is_closed_and_disallows_side_channels(field, value): + payload = response_payload() + payload["output"][1]["content"][0][field] = value + with pytest.raises(ValidationError): + ResponsesGuardedResponse.validate_payload(payload) + + +def test_only_declared_configurable_objects_allow_trusted_unknown_fields(): + payload = response_payload() + payload["output"][1]["future_metadata"] = 1 + ResponsesGuardedResponse.validate_payload(payload, unknown_content_fields=UnknownContentFieldPolicy.ALLOW) + payload["future_text"] = "hidden" + with pytest.raises(ValidationError): + ResponsesGuardedResponse.validate_payload(payload, unknown_content_fields=UnknownContentFieldPolicy.ALLOW) + + +def test_declared_payload_coverage_is_derived_not_a_second_inventory(): + from nemoguardrails.server.experimental.provider.projection_policy import payload_contract + + for model, direction in ((ResponsesGuardedRequest, "request"), (ResponsesGuardedResponse, "response")): + assert model.projection_contract == payload_contract( + model, projection_id=model.projection_contract.projection_id, direction=direction + ) From 6f17b76e8fe61ac4b92b149b80d752b92007e417 Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:37:21 +0200 Subject: [PATCH 03/11] feat(server): bind typed Responses events to streaming protocol hooks Classify text deltas, verified snapshots, reviewed reasoning metadata, and provider errors through the existing kernel. Keep native completion, supplied identity and sequence checks, and Responses error framing in request-local hooks. Reject uninspected citations and token logprobs, and export the bound event declarations without serializing hook behavior. Validation: Responses streaming/export tests pass. The experimental suite passes 1599 tests and retains the existing real-core shutdown timeout failure; lifecycle fixes remain separate. Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- .../openai/_generated/responses.guard.yaml | 2730 +++++++++++++++++ .../providers/openai/responses/endpoint.py | 4 + .../openai/responses/stream_classifier.py | 117 + .../openai/responses/stream_hooks.py | 96 + .../openai/responses/stream_projection.py | 202 ++ .../test_openai_responses_contract_export.py | 21 + ..._openai_responses_streaming_integration.py | 172 ++ ..._openai_responses_streaming_projections.py | 170 + 8 files changed, 3512 insertions(+) create mode 100644 nemoguardrails/server/experimental/providers/openai/responses/stream_classifier.py create mode 100644 nemoguardrails/server/experimental/providers/openai/responses/stream_hooks.py create mode 100644 nemoguardrails/server/experimental/providers/openai/responses/stream_projection.py create mode 100644 tests/server/experimental/test_openai_responses_streaming_integration.py create mode 100644 tests/server/experimental/test_openai_responses_streaming_projections.py diff --git a/nemoguardrails/server/experimental/contracts/openai/_generated/responses.guard.yaml b/nemoguardrails/server/experimental/contracts/openai/_generated/responses.guard.yaml index acbfeb00e6..1054bef018 100644 --- a/nemoguardrails/server/experimental/contracts/openai/_generated/responses.guard.yaml +++ b/nemoguardrails/server/experimental/contracts/openai/_generated/responses.guard.yaml @@ -520,3 +520,2733 @@ integration: operation_label: OpenAI Responses unsupported_request_code: unsupported_responses_shape unsupported_response_code: unsupported_responses_response_shape +stream: + title: openai.responses.stream.text.v1 + oneOf: + - additionalProperties: false + description: Only this event introduces new guarded assistant text. + properties: + content_index: + default: null + x-nemo-guardrails: + classification: constrained + oneOf: + - maximum: 0 + minimum: 0 + type: integer + - type: 'null' + delta: + type: string + x-nemo-guardrails: + classification: guarded + subject: + kind: text + replaceable: false + role: assistant + item_id: + default: null + x-nemo-guardrails: + classification: constrained + oneOf: + - type: string + - type: 'null' + logprobs: + default: null + x-nemo-guardrails: + classification: constrained + reason: provider_integrity.token_logprobs + oneOf: + - items: {} + maxItems: 0 + type: array + - type: 'null' + obfuscation: + x-nemo-guardrails: + classification: opaque + output_index: + default: null + x-nemo-guardrails: + classification: constrained + oneOf: + - minimum: 0 + type: integer + - type: 'null' + sequence_number: + default: null + x-nemo-guardrails: + classification: constrained + oneOf: + - minimum: 0 + type: integer + - type: 'null' + type: + const: response.output_text.delta + type: string + x-nemo-guardrails: + classification: constrained + required: + - type + - delta + title: ResponsesTextDeltaEventProjection + type: object + x-nemo-guardrails: + source: '#/components/schemas/ResponseTextDeltaEvent' + unknown_fields: forbid + reject_case_aliases: true + model: ResponsesTextDeltaEventProjection + event: + classification: guarded_delta + variants: + - source_schema: ResponseTextDeltaEvent + shape: response.output_text.delta + match: + type: response.output_text.delta + missing_text: reject + - additionalProperties: false + description: Repeat the accumulated text rather than introducing a second text + source. + properties: + content_index: + default: null + x-nemo-guardrails: + classification: constrained + oneOf: + - maximum: 0 + minimum: 0 + type: integer + - type: 'null' + item_id: + default: null + x-nemo-guardrails: + classification: constrained + oneOf: + - type: string + - type: 'null' + logprobs: + default: null + x-nemo-guardrails: + classification: constrained + reason: provider_integrity.token_logprobs + oneOf: + - items: {} + maxItems: 0 + type: array + - type: 'null' + output_index: + default: null + x-nemo-guardrails: + classification: constrained + oneOf: + - minimum: 0 + type: integer + - type: 'null' + sequence_number: + default: null + x-nemo-guardrails: + classification: constrained + oneOf: + - minimum: 0 + type: integer + - type: 'null' + text: + type: string + x-nemo-guardrails: + classification: guarded + subject: + kind: text + replaceable: false + role: assistant + type: + const: response.output_text.done + type: string + x-nemo-guardrails: + classification: constrained + required: + - type + - text + title: ResponsesTextDoneEventProjection + type: object + x-nemo-guardrails: + source: '#/components/schemas/ResponseTextDoneEvent' + unknown_fields: forbid + reject_case_aliases: true + model: ResponsesTextDoneEventProjection + event: + classification: snapshot + variants: + - source_schema: ResponseTextDoneEvent + shape: response.output_text.done + match: + type: response.output_text.done + missing_text: reject + - additionalProperties: false + description: A reviewed text part snapshot, including its initial empty state. + properties: + content_index: + default: null + x-nemo-guardrails: + classification: constrained + oneOf: + - maximum: 0 + minimum: 0 + type: integer + - type: 'null' + item_id: + default: null + x-nemo-guardrails: + classification: constrained + oneOf: + - type: string + - type: 'null' + output_index: + default: null + x-nemo-guardrails: + classification: constrained + oneOf: + - minimum: 0 + type: integer + - type: 'null' + part: + additionalProperties: false + description: An empty initial text snapshot is valid; annotations and logprobs + stay restricted. + properties: + annotations: + default: null + x-nemo-guardrails: + classification: constrained + reason: core_capability.citation_content + oneOf: + - items: {} + maxItems: 0 + type: array + - type: 'null' + logprobs: + default: null + x-nemo-guardrails: + classification: constrained + reason: provider_integrity.token_logprobs + oneOf: + - items: {} + maxItems: 0 + type: array + - type: 'null' + text: + type: string + x-nemo-guardrails: + classification: guarded + subject: + kind: text + replaceable: false + role: assistant + type: + const: output_text + type: string + x-nemo-guardrails: + classification: constrained + required: + - type + - text + title: ResponsesSnapshotTextProjection + type: object + x-nemo-guardrails: + classification: guarded + source: '#/components/schemas/OutputTextContent' + unknown_fields: configurable + reject_case_aliases: true + sequence_number: + default: null + x-nemo-guardrails: + classification: constrained + oneOf: + - minimum: 0 + type: integer + - type: 'null' + type: + const: response.content_part.added + type: string + x-nemo-guardrails: + classification: constrained + required: + - type + - part + title: ResponsesContentPartAddedEventProjection + type: object + x-nemo-guardrails: + source: '#/components/schemas/ResponseContentPartAddedEvent' + unknown_fields: forbid + reject_case_aliases: true + model: ResponsesContentPartAddedEventProjection + event: + classification: snapshot + variants: + - source_schema: ResponseContentPartAddedEvent + shape: response.content_part.added + match: + type: response.content_part.added + missing_text: reject + - additionalProperties: false + description: The final snapshot of the single text part. + properties: + content_index: + default: null + x-nemo-guardrails: + classification: constrained + oneOf: + - maximum: 0 + minimum: 0 + type: integer + - type: 'null' + item_id: + default: null + x-nemo-guardrails: + classification: constrained + oneOf: + - type: string + - type: 'null' + output_index: + default: null + x-nemo-guardrails: + classification: constrained + oneOf: + - minimum: 0 + type: integer + - type: 'null' + part: + additionalProperties: false + description: An empty initial text snapshot is valid; annotations and logprobs + stay restricted. + properties: + annotations: + default: null + x-nemo-guardrails: + classification: constrained + reason: core_capability.citation_content + oneOf: + - items: {} + maxItems: 0 + type: array + - type: 'null' + logprobs: + default: null + x-nemo-guardrails: + classification: constrained + reason: provider_integrity.token_logprobs + oneOf: + - items: {} + maxItems: 0 + type: array + - type: 'null' + text: + type: string + x-nemo-guardrails: + classification: guarded + subject: + kind: text + replaceable: false + role: assistant + type: + const: output_text + type: string + x-nemo-guardrails: + classification: constrained + required: + - type + - text + title: ResponsesSnapshotTextProjection + type: object + x-nemo-guardrails: + classification: guarded + source: '#/components/schemas/OutputTextContent' + unknown_fields: configurable + reject_case_aliases: true + sequence_number: + default: null + x-nemo-guardrails: + classification: constrained + oneOf: + - minimum: 0 + type: integer + - type: 'null' + type: + const: response.content_part.done + type: string + x-nemo-guardrails: + classification: constrained + required: + - type + - part + title: ResponsesContentPartDoneEventProjection + type: object + x-nemo-guardrails: + source: '#/components/schemas/ResponseContentPartDoneEvent' + unknown_fields: forbid + reject_case_aliases: true + model: ResponsesContentPartDoneEventProjection + event: + classification: snapshot + variants: + - source_schema: ResponseContentPartDoneEvent + shape: response.content_part.done + match: + type: response.content_part.done + missing_text: reject + - additionalProperties: false + description: The assistant item is a snapshot, not opaque output. + properties: + item: + additionalProperties: false + description: Allow an initially empty message, but never a second guarded + text part. + properties: + content: + items: + additionalProperties: false + description: An empty initial text snapshot is valid; annotations and + logprobs stay restricted. + properties: + annotations: + default: null + x-nemo-guardrails: + classification: constrained + reason: core_capability.citation_content + oneOf: + - items: {} + maxItems: 0 + type: array + - type: 'null' + logprobs: + default: null + x-nemo-guardrails: + classification: constrained + reason: provider_integrity.token_logprobs + oneOf: + - items: {} + maxItems: 0 + type: array + - type: 'null' + text: + type: string + x-nemo-guardrails: + classification: guarded + subject: + kind: text + replaceable: false + role: assistant + type: + const: output_text + type: string + x-nemo-guardrails: + classification: constrained + required: + - type + - text + title: ResponsesSnapshotTextProjection + type: object + x-nemo-guardrails: + source: '#/components/schemas/OutputTextContent' + unknown_fields: configurable + reject_case_aliases: true + maxItems: 1 + type: array + x-nemo-guardrails: + classification: guarded + id: + default: null + x-nemo-guardrails: + classification: constrained + oneOf: + - type: string + - type: 'null' + phase: + x-nemo-guardrails: + classification: opaque + role: + const: assistant + type: string + x-nemo-guardrails: + classification: constrained + status: + x-nemo-guardrails: + classification: opaque + type: + const: message + type: string + x-nemo-guardrails: + classification: constrained + required: + - type + - role + - content + title: ResponsesSnapshotMessageProjection + type: object + x-nemo-guardrails: + classification: guarded + variant: + cardinality: at_most_one + match: + role: assistant + type: message + source: '#/components/schemas/OutputMessage' + unknown_fields: configurable + reject_case_aliases: true + output_index: + default: null + x-nemo-guardrails: + classification: constrained + oneOf: + - minimum: 0 + type: integer + - type: 'null' + sequence_number: + default: null + x-nemo-guardrails: + classification: constrained + oneOf: + - minimum: 0 + type: integer + - type: 'null' + type: + const: response.output_item.added + type: string + x-nemo-guardrails: + classification: constrained + required: + - type + - item + title: ResponsesMessageAddedEventProjection + type: object + x-nemo-guardrails: + source: '#/components/schemas/ResponseOutputItemAddedEvent' + unknown_fields: forbid + reject_case_aliases: true + model: ResponsesMessageAddedEventProjection + event: + classification: snapshot + variants: + - source_schema: ResponseOutputItemAddedEvent + shape: response.output_item.added:message + match: + type: response.output_item.added + item.type: message + missing_text: empty + - additionalProperties: false + description: The completed assistant item must agree with previously observed + deltas. + properties: + item: + additionalProperties: false + description: Allow an initially empty message, but never a second guarded + text part. + properties: + content: + items: + additionalProperties: false + description: An empty initial text snapshot is valid; annotations and + logprobs stay restricted. + properties: + annotations: + default: null + x-nemo-guardrails: + classification: constrained + reason: core_capability.citation_content + oneOf: + - items: {} + maxItems: 0 + type: array + - type: 'null' + logprobs: + default: null + x-nemo-guardrails: + classification: constrained + reason: provider_integrity.token_logprobs + oneOf: + - items: {} + maxItems: 0 + type: array + - type: 'null' + text: + type: string + x-nemo-guardrails: + classification: guarded + subject: + kind: text + replaceable: false + role: assistant + type: + const: output_text + type: string + x-nemo-guardrails: + classification: constrained + required: + - type + - text + title: ResponsesSnapshotTextProjection + type: object + x-nemo-guardrails: + source: '#/components/schemas/OutputTextContent' + unknown_fields: configurable + reject_case_aliases: true + maxItems: 1 + type: array + x-nemo-guardrails: + classification: guarded + id: + default: null + x-nemo-guardrails: + classification: constrained + oneOf: + - type: string + - type: 'null' + phase: + x-nemo-guardrails: + classification: opaque + role: + const: assistant + type: string + x-nemo-guardrails: + classification: constrained + status: + x-nemo-guardrails: + classification: opaque + type: + const: message + type: string + x-nemo-guardrails: + classification: constrained + required: + - type + - role + - content + title: ResponsesSnapshotMessageProjection + type: object + x-nemo-guardrails: + classification: guarded + variant: + cardinality: at_most_one + match: + role: assistant + type: message + source: '#/components/schemas/OutputMessage' + unknown_fields: configurable + reject_case_aliases: true + output_index: + default: null + x-nemo-guardrails: + classification: constrained + oneOf: + - minimum: 0 + type: integer + - type: 'null' + sequence_number: + default: null + x-nemo-guardrails: + classification: constrained + oneOf: + - minimum: 0 + type: integer + - type: 'null' + type: + const: response.output_item.done + type: string + x-nemo-guardrails: + classification: constrained + required: + - type + - item + title: ResponsesMessageDoneEventProjection + type: object + x-nemo-guardrails: + source: '#/components/schemas/ResponseOutputItemDoneEvent' + unknown_fields: forbid + reject_case_aliases: true + model: ResponsesMessageDoneEventProjection + event: + classification: snapshot + variants: + - source_schema: ResponseOutputItemDoneEvent + shape: response.output_item.done:message + match: + type: response.output_item.done + item.type: message + missing_text: empty + - additionalProperties: false + description: Creation is a nonterminal snapshot. + properties: + response: + additionalProperties: false + description: Describe a full response snapshot without demanding a message + before generation. + properties: + background: + x-nemo-guardrails: + classification: opaque + completed_at: + x-nemo-guardrails: + classification: opaque + conversation: + x-nemo-guardrails: + classification: opaque + created_at: + x-nemo-guardrails: + classification: opaque + error: + x-nemo-guardrails: + classification: opaque + id: + x-nemo-guardrails: + classification: opaque + incomplete_details: + x-nemo-guardrails: + classification: opaque + instructions: + x-nemo-guardrails: + classification: opaque + max_output_tokens: + x-nemo-guardrails: + classification: opaque + max_tool_calls: + x-nemo-guardrails: + classification: opaque + metadata: + x-nemo-guardrails: + classification: opaque + model: + x-nemo-guardrails: + classification: opaque + moderation: + x-nemo-guardrails: + classification: opaque + object: + x-nemo-guardrails: + classification: opaque + output: + items: + oneOf: + - additionalProperties: false + description: Preserve opaque reasoning metadata, not separate plaintext + reasoning. + properties: + content: + default: null + x-nemo-guardrails: + classification: constrained + reason: core_capability.reasoning_content + oneOf: + - items: {} + maxItems: 0 + type: array + - type: 'null' + encrypted_content: + x-nemo-guardrails: + classification: opaque + id: + x-nemo-guardrails: + classification: opaque + status: + x-nemo-guardrails: + classification: opaque + summary: + default: null + x-nemo-guardrails: + classification: constrained + reason: core_capability.reasoning_content + oneOf: + - items: {} + maxItems: 0 + type: array + - type: 'null' + type: + const: reasoning + type: string + x-nemo-guardrails: + classification: constrained + required: + - type + title: ResponsesReasoningItemProjection + type: object + x-nemo-guardrails: + variant: + cardinality: any + match: + type: reasoning + source: '#/components/schemas/ReasoningItem' + unknown_fields: configurable + reject_case_aliases: true + - additionalProperties: false + description: Allow an initially empty message, but never a second + guarded text part. + properties: + content: + items: + additionalProperties: false + description: An empty initial text snapshot is valid; annotations + and logprobs stay restricted. + properties: + annotations: + default: null + x-nemo-guardrails: + classification: constrained + reason: core_capability.citation_content + oneOf: + - items: {} + maxItems: 0 + type: array + - type: 'null' + logprobs: + default: null + x-nemo-guardrails: + classification: constrained + reason: provider_integrity.token_logprobs + oneOf: + - items: {} + maxItems: 0 + type: array + - type: 'null' + text: + type: string + x-nemo-guardrails: + classification: guarded + subject: + kind: text + replaceable: false + role: assistant + type: + const: output_text + type: string + x-nemo-guardrails: + classification: constrained + required: + - type + - text + title: ResponsesSnapshotTextProjection + type: object + x-nemo-guardrails: + source: '#/components/schemas/OutputTextContent' + unknown_fields: configurable + reject_case_aliases: true + maxItems: 1 + type: array + x-nemo-guardrails: + classification: guarded + id: + default: null + x-nemo-guardrails: + classification: constrained + oneOf: + - type: string + - type: 'null' + phase: + x-nemo-guardrails: + classification: opaque + role: + const: assistant + type: string + x-nemo-guardrails: + classification: constrained + status: + x-nemo-guardrails: + classification: opaque + type: + const: message + type: string + x-nemo-guardrails: + classification: constrained + required: + - type + - role + - content + title: ResponsesSnapshotMessageProjection + type: object + x-nemo-guardrails: + variant: + cardinality: at_most_one + match: + role: assistant + type: message + source: '#/components/schemas/OutputMessage' + unknown_fields: configurable + reject_case_aliases: true + type: array + x-nemo-guardrails: + classification: guarded + output_text: + default: null + type: 'null' + x-nemo-guardrails: + classification: constrained + gate: disabled + reason: provider_integrity.duplicate_text + parallel_tool_calls: + x-nemo-guardrails: + classification: opaque + previous_response_id: + x-nemo-guardrails: + classification: opaque + prompt: + x-nemo-guardrails: + classification: opaque + prompt_cache_diagnostics: + x-nemo-guardrails: + classification: opaque + prompt_cache_key: + x-nemo-guardrails: + classification: opaque + prompt_cache_options: + x-nemo-guardrails: + classification: opaque + prompt_cache_retention: + x-nemo-guardrails: + classification: opaque + reasoning: + x-nemo-guardrails: + classification: opaque + safety_identifier: + x-nemo-guardrails: + classification: opaque + service_tier: + x-nemo-guardrails: + classification: opaque + status: + x-nemo-guardrails: + classification: opaque + temperature: + x-nemo-guardrails: + classification: opaque + text: + x-nemo-guardrails: + classification: opaque + tool_choice: + x-nemo-guardrails: + classification: opaque + tools: + x-nemo-guardrails: + classification: opaque + top_logprobs: + x-nemo-guardrails: + classification: opaque + top_p: + x-nemo-guardrails: + classification: opaque + truncation: + x-nemo-guardrails: + classification: opaque + usage: + x-nemo-guardrails: + classification: opaque + user: + x-nemo-guardrails: + classification: opaque + required: + - output + title: ResponsesLifecycleResponseProjection + type: object + x-nemo-guardrails: + classification: guarded + source: '#/components/schemas/Response' + unknown_fields: forbid + reject_case_aliases: true + sequence_number: + default: null + x-nemo-guardrails: + classification: constrained + oneOf: + - minimum: 0 + type: integer + - type: 'null' + type: + const: response.created + type: string + x-nemo-guardrails: + classification: constrained + required: + - response + - type + title: ResponsesCreatedEventProjection + type: object + x-nemo-guardrails: + source: '#/components/schemas/ResponseCreatedEvent' + unknown_fields: forbid + reject_case_aliases: true + model: ResponsesCreatedEventProjection + event: + classification: snapshot + variants: + - source_schema: ResponseCreatedEvent + shape: response.created + match: + type: response.created + missing_text: empty + - additionalProperties: false + description: Progress is a nonterminal snapshot. + properties: + response: + additionalProperties: false + description: Describe a full response snapshot without demanding a message + before generation. + properties: + background: + x-nemo-guardrails: + classification: opaque + completed_at: + x-nemo-guardrails: + classification: opaque + conversation: + x-nemo-guardrails: + classification: opaque + created_at: + x-nemo-guardrails: + classification: opaque + error: + x-nemo-guardrails: + classification: opaque + id: + x-nemo-guardrails: + classification: opaque + incomplete_details: + x-nemo-guardrails: + classification: opaque + instructions: + x-nemo-guardrails: + classification: opaque + max_output_tokens: + x-nemo-guardrails: + classification: opaque + max_tool_calls: + x-nemo-guardrails: + classification: opaque + metadata: + x-nemo-guardrails: + classification: opaque + model: + x-nemo-guardrails: + classification: opaque + moderation: + x-nemo-guardrails: + classification: opaque + object: + x-nemo-guardrails: + classification: opaque + output: + items: + oneOf: + - additionalProperties: false + description: Preserve opaque reasoning metadata, not separate plaintext + reasoning. + properties: + content: + default: null + x-nemo-guardrails: + classification: constrained + reason: core_capability.reasoning_content + oneOf: + - items: {} + maxItems: 0 + type: array + - type: 'null' + encrypted_content: + x-nemo-guardrails: + classification: opaque + id: + x-nemo-guardrails: + classification: opaque + status: + x-nemo-guardrails: + classification: opaque + summary: + default: null + x-nemo-guardrails: + classification: constrained + reason: core_capability.reasoning_content + oneOf: + - items: {} + maxItems: 0 + type: array + - type: 'null' + type: + const: reasoning + type: string + x-nemo-guardrails: + classification: constrained + required: + - type + title: ResponsesReasoningItemProjection + type: object + x-nemo-guardrails: + variant: + cardinality: any + match: + type: reasoning + source: '#/components/schemas/ReasoningItem' + unknown_fields: configurable + reject_case_aliases: true + - additionalProperties: false + description: Allow an initially empty message, but never a second + guarded text part. + properties: + content: + items: + additionalProperties: false + description: An empty initial text snapshot is valid; annotations + and logprobs stay restricted. + properties: + annotations: + default: null + x-nemo-guardrails: + classification: constrained + reason: core_capability.citation_content + oneOf: + - items: {} + maxItems: 0 + type: array + - type: 'null' + logprobs: + default: null + x-nemo-guardrails: + classification: constrained + reason: provider_integrity.token_logprobs + oneOf: + - items: {} + maxItems: 0 + type: array + - type: 'null' + text: + type: string + x-nemo-guardrails: + classification: guarded + subject: + kind: text + replaceable: false + role: assistant + type: + const: output_text + type: string + x-nemo-guardrails: + classification: constrained + required: + - type + - text + title: ResponsesSnapshotTextProjection + type: object + x-nemo-guardrails: + source: '#/components/schemas/OutputTextContent' + unknown_fields: configurable + reject_case_aliases: true + maxItems: 1 + type: array + x-nemo-guardrails: + classification: guarded + id: + default: null + x-nemo-guardrails: + classification: constrained + oneOf: + - type: string + - type: 'null' + phase: + x-nemo-guardrails: + classification: opaque + role: + const: assistant + type: string + x-nemo-guardrails: + classification: constrained + status: + x-nemo-guardrails: + classification: opaque + type: + const: message + type: string + x-nemo-guardrails: + classification: constrained + required: + - type + - role + - content + title: ResponsesSnapshotMessageProjection + type: object + x-nemo-guardrails: + variant: + cardinality: at_most_one + match: + role: assistant + type: message + source: '#/components/schemas/OutputMessage' + unknown_fields: configurable + reject_case_aliases: true + type: array + x-nemo-guardrails: + classification: guarded + output_text: + default: null + type: 'null' + x-nemo-guardrails: + classification: constrained + gate: disabled + reason: provider_integrity.duplicate_text + parallel_tool_calls: + x-nemo-guardrails: + classification: opaque + previous_response_id: + x-nemo-guardrails: + classification: opaque + prompt: + x-nemo-guardrails: + classification: opaque + prompt_cache_diagnostics: + x-nemo-guardrails: + classification: opaque + prompt_cache_key: + x-nemo-guardrails: + classification: opaque + prompt_cache_options: + x-nemo-guardrails: + classification: opaque + prompt_cache_retention: + x-nemo-guardrails: + classification: opaque + reasoning: + x-nemo-guardrails: + classification: opaque + safety_identifier: + x-nemo-guardrails: + classification: opaque + service_tier: + x-nemo-guardrails: + classification: opaque + status: + x-nemo-guardrails: + classification: opaque + temperature: + x-nemo-guardrails: + classification: opaque + text: + x-nemo-guardrails: + classification: opaque + tool_choice: + x-nemo-guardrails: + classification: opaque + tools: + x-nemo-guardrails: + classification: opaque + top_logprobs: + x-nemo-guardrails: + classification: opaque + top_p: + x-nemo-guardrails: + classification: opaque + truncation: + x-nemo-guardrails: + classification: opaque + usage: + x-nemo-guardrails: + classification: opaque + user: + x-nemo-guardrails: + classification: opaque + required: + - output + title: ResponsesLifecycleResponseProjection + type: object + x-nemo-guardrails: + classification: guarded + source: '#/components/schemas/Response' + unknown_fields: forbid + reject_case_aliases: true + sequence_number: + default: null + x-nemo-guardrails: + classification: constrained + oneOf: + - minimum: 0 + type: integer + - type: 'null' + type: + const: response.in_progress + type: string + x-nemo-guardrails: + classification: constrained + required: + - response + - type + title: ResponsesInProgressEventProjection + type: object + x-nemo-guardrails: + source: '#/components/schemas/ResponseInProgressEvent' + unknown_fields: forbid + reject_case_aliases: true + model: ResponsesInProgressEventProjection + event: + classification: snapshot + variants: + - source_schema: ResponseInProgressEvent + shape: response.in_progress + match: + type: response.in_progress + missing_text: empty + - additionalProperties: false + description: Queue metadata does not make background execution an accepted request + mode. + properties: + response: + additionalProperties: false + description: Describe a full response snapshot without demanding a message + before generation. + properties: + background: + x-nemo-guardrails: + classification: opaque + completed_at: + x-nemo-guardrails: + classification: opaque + conversation: + x-nemo-guardrails: + classification: opaque + created_at: + x-nemo-guardrails: + classification: opaque + error: + x-nemo-guardrails: + classification: opaque + id: + x-nemo-guardrails: + classification: opaque + incomplete_details: + x-nemo-guardrails: + classification: opaque + instructions: + x-nemo-guardrails: + classification: opaque + max_output_tokens: + x-nemo-guardrails: + classification: opaque + max_tool_calls: + x-nemo-guardrails: + classification: opaque + metadata: + x-nemo-guardrails: + classification: opaque + model: + x-nemo-guardrails: + classification: opaque + moderation: + x-nemo-guardrails: + classification: opaque + object: + x-nemo-guardrails: + classification: opaque + output: + items: + oneOf: + - additionalProperties: false + description: Preserve opaque reasoning metadata, not separate plaintext + reasoning. + properties: + content: + default: null + x-nemo-guardrails: + classification: constrained + reason: core_capability.reasoning_content + oneOf: + - items: {} + maxItems: 0 + type: array + - type: 'null' + encrypted_content: + x-nemo-guardrails: + classification: opaque + id: + x-nemo-guardrails: + classification: opaque + status: + x-nemo-guardrails: + classification: opaque + summary: + default: null + x-nemo-guardrails: + classification: constrained + reason: core_capability.reasoning_content + oneOf: + - items: {} + maxItems: 0 + type: array + - type: 'null' + type: + const: reasoning + type: string + x-nemo-guardrails: + classification: constrained + required: + - type + title: ResponsesReasoningItemProjection + type: object + x-nemo-guardrails: + variant: + cardinality: any + match: + type: reasoning + source: '#/components/schemas/ReasoningItem' + unknown_fields: configurable + reject_case_aliases: true + - additionalProperties: false + description: Allow an initially empty message, but never a second + guarded text part. + properties: + content: + items: + additionalProperties: false + description: An empty initial text snapshot is valid; annotations + and logprobs stay restricted. + properties: + annotations: + default: null + x-nemo-guardrails: + classification: constrained + reason: core_capability.citation_content + oneOf: + - items: {} + maxItems: 0 + type: array + - type: 'null' + logprobs: + default: null + x-nemo-guardrails: + classification: constrained + reason: provider_integrity.token_logprobs + oneOf: + - items: {} + maxItems: 0 + type: array + - type: 'null' + text: + type: string + x-nemo-guardrails: + classification: guarded + subject: + kind: text + replaceable: false + role: assistant + type: + const: output_text + type: string + x-nemo-guardrails: + classification: constrained + required: + - type + - text + title: ResponsesSnapshotTextProjection + type: object + x-nemo-guardrails: + source: '#/components/schemas/OutputTextContent' + unknown_fields: configurable + reject_case_aliases: true + maxItems: 1 + type: array + x-nemo-guardrails: + classification: guarded + id: + default: null + x-nemo-guardrails: + classification: constrained + oneOf: + - type: string + - type: 'null' + phase: + x-nemo-guardrails: + classification: opaque + role: + const: assistant + type: string + x-nemo-guardrails: + classification: constrained + status: + x-nemo-guardrails: + classification: opaque + type: + const: message + type: string + x-nemo-guardrails: + classification: constrained + required: + - type + - role + - content + title: ResponsesSnapshotMessageProjection + type: object + x-nemo-guardrails: + variant: + cardinality: at_most_one + match: + role: assistant + type: message + source: '#/components/schemas/OutputMessage' + unknown_fields: configurable + reject_case_aliases: true + type: array + x-nemo-guardrails: + classification: guarded + output_text: + default: null + type: 'null' + x-nemo-guardrails: + classification: constrained + gate: disabled + reason: provider_integrity.duplicate_text + parallel_tool_calls: + x-nemo-guardrails: + classification: opaque + previous_response_id: + x-nemo-guardrails: + classification: opaque + prompt: + x-nemo-guardrails: + classification: opaque + prompt_cache_diagnostics: + x-nemo-guardrails: + classification: opaque + prompt_cache_key: + x-nemo-guardrails: + classification: opaque + prompt_cache_options: + x-nemo-guardrails: + classification: opaque + prompt_cache_retention: + x-nemo-guardrails: + classification: opaque + reasoning: + x-nemo-guardrails: + classification: opaque + safety_identifier: + x-nemo-guardrails: + classification: opaque + service_tier: + x-nemo-guardrails: + classification: opaque + status: + x-nemo-guardrails: + classification: opaque + temperature: + x-nemo-guardrails: + classification: opaque + text: + x-nemo-guardrails: + classification: opaque + tool_choice: + x-nemo-guardrails: + classification: opaque + tools: + x-nemo-guardrails: + classification: opaque + top_logprobs: + x-nemo-guardrails: + classification: opaque + top_p: + x-nemo-guardrails: + classification: opaque + truncation: + x-nemo-guardrails: + classification: opaque + usage: + x-nemo-guardrails: + classification: opaque + user: + x-nemo-guardrails: + classification: opaque + required: + - output + title: ResponsesLifecycleResponseProjection + type: object + x-nemo-guardrails: + classification: guarded + source: '#/components/schemas/Response' + unknown_fields: forbid + reject_case_aliases: true + sequence_number: + default: null + x-nemo-guardrails: + classification: constrained + oneOf: + - minimum: 0 + type: integer + - type: 'null' + type: + const: response.queued + type: string + x-nemo-guardrails: + classification: constrained + required: + - response + - type + title: ResponsesQueuedEventProjection + type: object + x-nemo-guardrails: + source: '#/components/schemas/ResponseQueuedEvent' + unknown_fields: forbid + reject_case_aliases: true + model: ResponsesQueuedEventProjection + event: + classification: snapshot + variants: + - source_schema: ResponseQueuedEvent + shape: response.queued + match: + type: response.queued + missing_text: empty + - additionalProperties: false + description: Successful native completion carries a final verified snapshot. + properties: + response: + additionalProperties: false + description: Describe a full response snapshot without demanding a message + before generation. + properties: + background: + x-nemo-guardrails: + classification: opaque + completed_at: + x-nemo-guardrails: + classification: opaque + conversation: + x-nemo-guardrails: + classification: opaque + created_at: + x-nemo-guardrails: + classification: opaque + error: + x-nemo-guardrails: + classification: opaque + id: + x-nemo-guardrails: + classification: opaque + incomplete_details: + x-nemo-guardrails: + classification: opaque + instructions: + x-nemo-guardrails: + classification: opaque + max_output_tokens: + x-nemo-guardrails: + classification: opaque + max_tool_calls: + x-nemo-guardrails: + classification: opaque + metadata: + x-nemo-guardrails: + classification: opaque + model: + x-nemo-guardrails: + classification: opaque + moderation: + x-nemo-guardrails: + classification: opaque + object: + x-nemo-guardrails: + classification: opaque + output: + items: + oneOf: + - additionalProperties: false + description: Preserve opaque reasoning metadata, not separate plaintext + reasoning. + properties: + content: + default: null + x-nemo-guardrails: + classification: constrained + reason: core_capability.reasoning_content + oneOf: + - items: {} + maxItems: 0 + type: array + - type: 'null' + encrypted_content: + x-nemo-guardrails: + classification: opaque + id: + x-nemo-guardrails: + classification: opaque + status: + x-nemo-guardrails: + classification: opaque + summary: + default: null + x-nemo-guardrails: + classification: constrained + reason: core_capability.reasoning_content + oneOf: + - items: {} + maxItems: 0 + type: array + - type: 'null' + type: + const: reasoning + type: string + x-nemo-guardrails: + classification: constrained + required: + - type + title: ResponsesReasoningItemProjection + type: object + x-nemo-guardrails: + variant: + cardinality: any + match: + type: reasoning + source: '#/components/schemas/ReasoningItem' + unknown_fields: configurable + reject_case_aliases: true + - additionalProperties: false + description: Allow an initially empty message, but never a second + guarded text part. + properties: + content: + items: + additionalProperties: false + description: An empty initial text snapshot is valid; annotations + and logprobs stay restricted. + properties: + annotations: + default: null + x-nemo-guardrails: + classification: constrained + reason: core_capability.citation_content + oneOf: + - items: {} + maxItems: 0 + type: array + - type: 'null' + logprobs: + default: null + x-nemo-guardrails: + classification: constrained + reason: provider_integrity.token_logprobs + oneOf: + - items: {} + maxItems: 0 + type: array + - type: 'null' + text: + type: string + x-nemo-guardrails: + classification: guarded + subject: + kind: text + replaceable: false + role: assistant + type: + const: output_text + type: string + x-nemo-guardrails: + classification: constrained + required: + - type + - text + title: ResponsesSnapshotTextProjection + type: object + x-nemo-guardrails: + source: '#/components/schemas/OutputTextContent' + unknown_fields: configurable + reject_case_aliases: true + maxItems: 1 + type: array + x-nemo-guardrails: + classification: guarded + id: + default: null + x-nemo-guardrails: + classification: constrained + oneOf: + - type: string + - type: 'null' + phase: + x-nemo-guardrails: + classification: opaque + role: + const: assistant + type: string + x-nemo-guardrails: + classification: constrained + status: + x-nemo-guardrails: + classification: opaque + type: + const: message + type: string + x-nemo-guardrails: + classification: constrained + required: + - type + - role + - content + title: ResponsesSnapshotMessageProjection + type: object + x-nemo-guardrails: + variant: + cardinality: at_most_one + match: + role: assistant + type: message + source: '#/components/schemas/OutputMessage' + unknown_fields: configurable + reject_case_aliases: true + type: array + x-nemo-guardrails: + classification: guarded + output_text: + default: null + type: 'null' + x-nemo-guardrails: + classification: constrained + gate: disabled + reason: provider_integrity.duplicate_text + parallel_tool_calls: + x-nemo-guardrails: + classification: opaque + previous_response_id: + x-nemo-guardrails: + classification: opaque + prompt: + x-nemo-guardrails: + classification: opaque + prompt_cache_diagnostics: + x-nemo-guardrails: + classification: opaque + prompt_cache_key: + x-nemo-guardrails: + classification: opaque + prompt_cache_options: + x-nemo-guardrails: + classification: opaque + prompt_cache_retention: + x-nemo-guardrails: + classification: opaque + reasoning: + x-nemo-guardrails: + classification: opaque + safety_identifier: + x-nemo-guardrails: + classification: opaque + service_tier: + x-nemo-guardrails: + classification: opaque + status: + x-nemo-guardrails: + classification: opaque + temperature: + x-nemo-guardrails: + classification: opaque + text: + x-nemo-guardrails: + classification: opaque + tool_choice: + x-nemo-guardrails: + classification: opaque + tools: + x-nemo-guardrails: + classification: opaque + top_logprobs: + x-nemo-guardrails: + classification: opaque + top_p: + x-nemo-guardrails: + classification: opaque + truncation: + x-nemo-guardrails: + classification: opaque + usage: + x-nemo-guardrails: + classification: opaque + user: + x-nemo-guardrails: + classification: opaque + required: + - output + title: ResponsesLifecycleResponseProjection + type: object + x-nemo-guardrails: + classification: guarded + source: '#/components/schemas/Response' + unknown_fields: forbid + reject_case_aliases: true + sequence_number: + default: null + x-nemo-guardrails: + classification: constrained + oneOf: + - minimum: 0 + type: integer + - type: 'null' + type: + const: response.completed + type: string + x-nemo-guardrails: + classification: constrained + required: + - response + - type + title: ResponsesCompletedEventProjection + type: object + x-nemo-guardrails: + source: '#/components/schemas/ResponseCompletedEvent' + unknown_fields: forbid + reject_case_aliases: true + model: ResponsesCompletedEventProjection + event: + classification: snapshot + variants: + - source_schema: ResponseCompletedEvent + shape: response.completed + match: + type: response.completed + missing_text: empty + - additionalProperties: false + description: Failure may still contain text and therefore remains a checked snapshot. + properties: + response: + additionalProperties: false + description: Describe a full response snapshot without demanding a message + before generation. + properties: + background: + x-nemo-guardrails: + classification: opaque + completed_at: + x-nemo-guardrails: + classification: opaque + conversation: + x-nemo-guardrails: + classification: opaque + created_at: + x-nemo-guardrails: + classification: opaque + error: + x-nemo-guardrails: + classification: opaque + id: + x-nemo-guardrails: + classification: opaque + incomplete_details: + x-nemo-guardrails: + classification: opaque + instructions: + x-nemo-guardrails: + classification: opaque + max_output_tokens: + x-nemo-guardrails: + classification: opaque + max_tool_calls: + x-nemo-guardrails: + classification: opaque + metadata: + x-nemo-guardrails: + classification: opaque + model: + x-nemo-guardrails: + classification: opaque + moderation: + x-nemo-guardrails: + classification: opaque + object: + x-nemo-guardrails: + classification: opaque + output: + items: + oneOf: + - additionalProperties: false + description: Preserve opaque reasoning metadata, not separate plaintext + reasoning. + properties: + content: + default: null + x-nemo-guardrails: + classification: constrained + reason: core_capability.reasoning_content + oneOf: + - items: {} + maxItems: 0 + type: array + - type: 'null' + encrypted_content: + x-nemo-guardrails: + classification: opaque + id: + x-nemo-guardrails: + classification: opaque + status: + x-nemo-guardrails: + classification: opaque + summary: + default: null + x-nemo-guardrails: + classification: constrained + reason: core_capability.reasoning_content + oneOf: + - items: {} + maxItems: 0 + type: array + - type: 'null' + type: + const: reasoning + type: string + x-nemo-guardrails: + classification: constrained + required: + - type + title: ResponsesReasoningItemProjection + type: object + x-nemo-guardrails: + variant: + cardinality: any + match: + type: reasoning + source: '#/components/schemas/ReasoningItem' + unknown_fields: configurable + reject_case_aliases: true + - additionalProperties: false + description: Allow an initially empty message, but never a second + guarded text part. + properties: + content: + items: + additionalProperties: false + description: An empty initial text snapshot is valid; annotations + and logprobs stay restricted. + properties: + annotations: + default: null + x-nemo-guardrails: + classification: constrained + reason: core_capability.citation_content + oneOf: + - items: {} + maxItems: 0 + type: array + - type: 'null' + logprobs: + default: null + x-nemo-guardrails: + classification: constrained + reason: provider_integrity.token_logprobs + oneOf: + - items: {} + maxItems: 0 + type: array + - type: 'null' + text: + type: string + x-nemo-guardrails: + classification: guarded + subject: + kind: text + replaceable: false + role: assistant + type: + const: output_text + type: string + x-nemo-guardrails: + classification: constrained + required: + - type + - text + title: ResponsesSnapshotTextProjection + type: object + x-nemo-guardrails: + source: '#/components/schemas/OutputTextContent' + unknown_fields: configurable + reject_case_aliases: true + maxItems: 1 + type: array + x-nemo-guardrails: + classification: guarded + id: + default: null + x-nemo-guardrails: + classification: constrained + oneOf: + - type: string + - type: 'null' + phase: + x-nemo-guardrails: + classification: opaque + role: + const: assistant + type: string + x-nemo-guardrails: + classification: constrained + status: + x-nemo-guardrails: + classification: opaque + type: + const: message + type: string + x-nemo-guardrails: + classification: constrained + required: + - type + - role + - content + title: ResponsesSnapshotMessageProjection + type: object + x-nemo-guardrails: + variant: + cardinality: at_most_one + match: + role: assistant + type: message + source: '#/components/schemas/OutputMessage' + unknown_fields: configurable + reject_case_aliases: true + type: array + x-nemo-guardrails: + classification: guarded + output_text: + default: null + type: 'null' + x-nemo-guardrails: + classification: constrained + gate: disabled + reason: provider_integrity.duplicate_text + parallel_tool_calls: + x-nemo-guardrails: + classification: opaque + previous_response_id: + x-nemo-guardrails: + classification: opaque + prompt: + x-nemo-guardrails: + classification: opaque + prompt_cache_diagnostics: + x-nemo-guardrails: + classification: opaque + prompt_cache_key: + x-nemo-guardrails: + classification: opaque + prompt_cache_options: + x-nemo-guardrails: + classification: opaque + prompt_cache_retention: + x-nemo-guardrails: + classification: opaque + reasoning: + x-nemo-guardrails: + classification: opaque + safety_identifier: + x-nemo-guardrails: + classification: opaque + service_tier: + x-nemo-guardrails: + classification: opaque + status: + x-nemo-guardrails: + classification: opaque + temperature: + x-nemo-guardrails: + classification: opaque + text: + x-nemo-guardrails: + classification: opaque + tool_choice: + x-nemo-guardrails: + classification: opaque + tools: + x-nemo-guardrails: + classification: opaque + top_logprobs: + x-nemo-guardrails: + classification: opaque + top_p: + x-nemo-guardrails: + classification: opaque + truncation: + x-nemo-guardrails: + classification: opaque + usage: + x-nemo-guardrails: + classification: opaque + user: + x-nemo-guardrails: + classification: opaque + required: + - output + title: ResponsesLifecycleResponseProjection + type: object + x-nemo-guardrails: + classification: guarded + source: '#/components/schemas/Response' + unknown_fields: forbid + reject_case_aliases: true + sequence_number: + default: null + x-nemo-guardrails: + classification: constrained + oneOf: + - minimum: 0 + type: integer + - type: 'null' + type: + const: response.failed + type: string + x-nemo-guardrails: + classification: constrained + required: + - response + - type + title: ResponsesFailedEventProjection + type: object + x-nemo-guardrails: + source: '#/components/schemas/ResponseFailedEvent' + unknown_fields: forbid + reject_case_aliases: true + model: ResponsesFailedEventProjection + event: + classification: snapshot + variants: + - source_schema: ResponseFailedEvent + shape: response.failed + match: + type: response.failed + missing_text: empty + - additionalProperties: false + description: An incomplete response is terminal, but its partial text is not opaque. + properties: + response: + additionalProperties: false + description: Describe a full response snapshot without demanding a message + before generation. + properties: + background: + x-nemo-guardrails: + classification: opaque + completed_at: + x-nemo-guardrails: + classification: opaque + conversation: + x-nemo-guardrails: + classification: opaque + created_at: + x-nemo-guardrails: + classification: opaque + error: + x-nemo-guardrails: + classification: opaque + id: + x-nemo-guardrails: + classification: opaque + incomplete_details: + x-nemo-guardrails: + classification: opaque + instructions: + x-nemo-guardrails: + classification: opaque + max_output_tokens: + x-nemo-guardrails: + classification: opaque + max_tool_calls: + x-nemo-guardrails: + classification: opaque + metadata: + x-nemo-guardrails: + classification: opaque + model: + x-nemo-guardrails: + classification: opaque + moderation: + x-nemo-guardrails: + classification: opaque + object: + x-nemo-guardrails: + classification: opaque + output: + items: + oneOf: + - additionalProperties: false + description: Preserve opaque reasoning metadata, not separate plaintext + reasoning. + properties: + content: + default: null + x-nemo-guardrails: + classification: constrained + reason: core_capability.reasoning_content + oneOf: + - items: {} + maxItems: 0 + type: array + - type: 'null' + encrypted_content: + x-nemo-guardrails: + classification: opaque + id: + x-nemo-guardrails: + classification: opaque + status: + x-nemo-guardrails: + classification: opaque + summary: + default: null + x-nemo-guardrails: + classification: constrained + reason: core_capability.reasoning_content + oneOf: + - items: {} + maxItems: 0 + type: array + - type: 'null' + type: + const: reasoning + type: string + x-nemo-guardrails: + classification: constrained + required: + - type + title: ResponsesReasoningItemProjection + type: object + x-nemo-guardrails: + variant: + cardinality: any + match: + type: reasoning + source: '#/components/schemas/ReasoningItem' + unknown_fields: configurable + reject_case_aliases: true + - additionalProperties: false + description: Allow an initially empty message, but never a second + guarded text part. + properties: + content: + items: + additionalProperties: false + description: An empty initial text snapshot is valid; annotations + and logprobs stay restricted. + properties: + annotations: + default: null + x-nemo-guardrails: + classification: constrained + reason: core_capability.citation_content + oneOf: + - items: {} + maxItems: 0 + type: array + - type: 'null' + logprobs: + default: null + x-nemo-guardrails: + classification: constrained + reason: provider_integrity.token_logprobs + oneOf: + - items: {} + maxItems: 0 + type: array + - type: 'null' + text: + type: string + x-nemo-guardrails: + classification: guarded + subject: + kind: text + replaceable: false + role: assistant + type: + const: output_text + type: string + x-nemo-guardrails: + classification: constrained + required: + - type + - text + title: ResponsesSnapshotTextProjection + type: object + x-nemo-guardrails: + source: '#/components/schemas/OutputTextContent' + unknown_fields: configurable + reject_case_aliases: true + maxItems: 1 + type: array + x-nemo-guardrails: + classification: guarded + id: + default: null + x-nemo-guardrails: + classification: constrained + oneOf: + - type: string + - type: 'null' + phase: + x-nemo-guardrails: + classification: opaque + role: + const: assistant + type: string + x-nemo-guardrails: + classification: constrained + status: + x-nemo-guardrails: + classification: opaque + type: + const: message + type: string + x-nemo-guardrails: + classification: constrained + required: + - type + - role + - content + title: ResponsesSnapshotMessageProjection + type: object + x-nemo-guardrails: + variant: + cardinality: at_most_one + match: + role: assistant + type: message + source: '#/components/schemas/OutputMessage' + unknown_fields: configurable + reject_case_aliases: true + type: array + x-nemo-guardrails: + classification: guarded + output_text: + default: null + type: 'null' + x-nemo-guardrails: + classification: constrained + gate: disabled + reason: provider_integrity.duplicate_text + parallel_tool_calls: + x-nemo-guardrails: + classification: opaque + previous_response_id: + x-nemo-guardrails: + classification: opaque + prompt: + x-nemo-guardrails: + classification: opaque + prompt_cache_diagnostics: + x-nemo-guardrails: + classification: opaque + prompt_cache_key: + x-nemo-guardrails: + classification: opaque + prompt_cache_options: + x-nemo-guardrails: + classification: opaque + prompt_cache_retention: + x-nemo-guardrails: + classification: opaque + reasoning: + x-nemo-guardrails: + classification: opaque + safety_identifier: + x-nemo-guardrails: + classification: opaque + service_tier: + x-nemo-guardrails: + classification: opaque + status: + x-nemo-guardrails: + classification: opaque + temperature: + x-nemo-guardrails: + classification: opaque + text: + x-nemo-guardrails: + classification: opaque + tool_choice: + x-nemo-guardrails: + classification: opaque + tools: + x-nemo-guardrails: + classification: opaque + top_logprobs: + x-nemo-guardrails: + classification: opaque + top_p: + x-nemo-guardrails: + classification: opaque + truncation: + x-nemo-guardrails: + classification: opaque + usage: + x-nemo-guardrails: + classification: opaque + user: + x-nemo-guardrails: + classification: opaque + required: + - output + title: ResponsesLifecycleResponseProjection + type: object + x-nemo-guardrails: + classification: guarded + source: '#/components/schemas/Response' + unknown_fields: forbid + reject_case_aliases: true + sequence_number: + default: null + x-nemo-guardrails: + classification: constrained + oneOf: + - minimum: 0 + type: integer + - type: 'null' + type: + const: response.incomplete + type: string + x-nemo-guardrails: + classification: constrained + required: + - response + - type + title: ResponsesIncompleteEventProjection + type: object + x-nemo-guardrails: + source: '#/components/schemas/ResponseIncompleteEvent' + unknown_fields: forbid + reject_case_aliases: true + model: ResponsesIncompleteEventProjection + event: + classification: snapshot + variants: + - source_schema: ResponseIncompleteEvent + shape: response.incomplete + match: + type: response.incomplete + missing_text: empty + - additionalProperties: false + description: Allow only the reviewed, non-plaintext reasoning item. + properties: + item: + additionalProperties: false + description: Preserve opaque reasoning metadata, not separate plaintext reasoning. + properties: + content: + default: null + x-nemo-guardrails: + classification: constrained + reason: core_capability.reasoning_content + oneOf: + - items: {} + maxItems: 0 + type: array + - type: 'null' + encrypted_content: + x-nemo-guardrails: + classification: opaque + id: + x-nemo-guardrails: + classification: opaque + status: + x-nemo-guardrails: + classification: opaque + summary: + default: null + x-nemo-guardrails: + classification: constrained + reason: core_capability.reasoning_content + oneOf: + - items: {} + maxItems: 0 + type: array + - type: 'null' + type: + const: reasoning + type: string + x-nemo-guardrails: + classification: constrained + required: + - type + title: ResponsesReasoningItemProjection + type: object + x-nemo-guardrails: + classification: constrained + variant: + cardinality: any + match: + type: reasoning + source: '#/components/schemas/ReasoningItem' + unknown_fields: configurable + reject_case_aliases: true + output_index: + default: null + x-nemo-guardrails: + classification: constrained + oneOf: + - minimum: 0 + type: integer + - type: 'null' + sequence_number: + default: null + x-nemo-guardrails: + classification: constrained + oneOf: + - minimum: 0 + type: integer + - type: 'null' + type: + const: response.output_item.added + type: string + x-nemo-guardrails: + classification: constrained + required: + - type + - item + title: ResponsesReasoningAddedEventProjection + type: object + x-nemo-guardrails: + source: '#/components/schemas/ResponseOutputItemAddedEvent' + unknown_fields: forbid + reject_case_aliases: true + model: ResponsesReasoningAddedEventProjection + event: + classification: opaque + variants: + - source_schema: ResponseOutputItemAddedEvent + shape: response.output_item.added:reasoning + match: + type: response.output_item.added + item.type: reasoning + - additionalProperties: false + description: Preserve completed reasoning metadata without inspecting encrypted + values. + properties: + item: + additionalProperties: false + description: Preserve opaque reasoning metadata, not separate plaintext reasoning. + properties: + content: + default: null + x-nemo-guardrails: + classification: constrained + reason: core_capability.reasoning_content + oneOf: + - items: {} + maxItems: 0 + type: array + - type: 'null' + encrypted_content: + x-nemo-guardrails: + classification: opaque + id: + x-nemo-guardrails: + classification: opaque + status: + x-nemo-guardrails: + classification: opaque + summary: + default: null + x-nemo-guardrails: + classification: constrained + reason: core_capability.reasoning_content + oneOf: + - items: {} + maxItems: 0 + type: array + - type: 'null' + type: + const: reasoning + type: string + x-nemo-guardrails: + classification: constrained + required: + - type + title: ResponsesReasoningItemProjection + type: object + x-nemo-guardrails: + classification: constrained + variant: + cardinality: any + match: + type: reasoning + source: '#/components/schemas/ReasoningItem' + unknown_fields: configurable + reject_case_aliases: true + output_index: + default: null + x-nemo-guardrails: + classification: constrained + oneOf: + - minimum: 0 + type: integer + - type: 'null' + sequence_number: + default: null + x-nemo-guardrails: + classification: constrained + oneOf: + - minimum: 0 + type: integer + - type: 'null' + type: + const: response.output_item.done + type: string + x-nemo-guardrails: + classification: constrained + required: + - type + - item + title: ResponsesReasoningDoneEventProjection + type: object + x-nemo-guardrails: + source: '#/components/schemas/ResponseOutputItemDoneEvent' + unknown_fields: forbid + reject_case_aliases: true + model: ResponsesReasoningDoneEventProjection + event: + classification: opaque + variants: + - source_schema: ResponseOutputItemDoneEvent + shape: response.output_item.done:reasoning + match: + type: response.output_item.done + item.type: reasoning + - additionalProperties: false + description: A provider error is terminal diagnostic data, not assistant output. + properties: + code: + x-nemo-guardrails: + classification: opaque + message: + type: string + x-nemo-guardrails: + classification: constrained + param: + x-nemo-guardrails: + classification: opaque + sequence_number: + default: null + x-nemo-guardrails: + classification: constrained + oneOf: + - minimum: 0 + type: integer + - type: 'null' + type: + const: error + type: string + x-nemo-guardrails: + classification: constrained + required: + - type + - message + title: ResponsesErrorEventProjection + type: object + x-nemo-guardrails: + source: '#/components/schemas/ResponseErrorEvent' + unknown_fields: forbid + reject_case_aliases: true + model: ResponsesErrorEventProjection + event: + classification: provider_error + variants: + - source_schema: ResponseErrorEvent + shape: error + match: + type: error + x-nemo-guardrails: + transport: + require_sse_event: false + data_discriminator: type + non_data_shape: keepalive + sentinels: + '[DONE]': done diff --git a/nemoguardrails/server/experimental/providers/openai/responses/endpoint.py b/nemoguardrails/server/experimental/providers/openai/responses/endpoint.py index 59540bb2d5..ecaa30d1b7 100644 --- a/nemoguardrails/server/experimental/providers/openai/responses/endpoint.py +++ b/nemoguardrails/server/experimental/providers/openai/responses/endpoint.py @@ -17,8 +17,11 @@ from nemoguardrails.server.experimental._http_paths import GuardedOperationPath from nemoguardrails.server.experimental.provider.endpoint import GuardedJsonEndpoint +from nemoguardrails.server.experimental.provider.stream import StreamBinding from nemoguardrails.server.experimental.providers.openai.responses.request_binding import ResponsesGuardedRequest from nemoguardrails.server.experimental.providers.openai.responses.response_binding import ResponsesGuardedResponse +from nemoguardrails.server.experimental.providers.openai.responses.stream_classifier import STREAM_CLASSIFIER +from nemoguardrails.server.experimental.providers.openai.responses.stream_hooks import ResponsesStreamHooks RESPONSES_ENDPOINT = GuardedJsonEndpoint( provider_operation_id="createResponse", @@ -35,4 +38,5 @@ GuardedOperationPath("/v1/responses", frozenset({"POST"})), GuardedOperationPath("/{api_version}/responses", frozenset({"POST"})), ), + stream=StreamBinding(STREAM_CLASSIFIER, ResponsesStreamHooks), ) diff --git a/nemoguardrails/server/experimental/providers/openai/responses/stream_classifier.py b/nemoguardrails/server/experimental/providers/openai/responses/stream_classifier.py new file mode 100644 index 0000000000..3ac78728fe --- /dev/null +++ b/nemoguardrails/server/experimental/providers/openai/responses/stream_classifier.py @@ -0,0 +1,117 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Bind reviewed Responses event roles to text paths derived from typed policy.""" + +from nemoguardrails.server.experimental.provider.projection_policy import PolicyModel +from nemoguardrails.server.experimental.provider.stream import ( + StreamCapabilityProfile, + StreamEventRole, + StreamProjectionContract, +) +from nemoguardrails.server.experimental.provider.stream_classifier import StreamClassifierDefinition, StreamEventRule +from nemoguardrails.server.experimental.provider.stream_policy import ( + build_policy_stream_classifier, + stream_shape_coverage, + stream_text_path, +) +from nemoguardrails.server.experimental.providers.openai.responses import stream_projection as models + +PROJECTION_ID = "openai.responses.stream.text.v1" +STREAM_SENTINELS = ((b"[DONE]", "done"),) +STREAM_NON_DATA_SHAPE = "keepalive" + + +def _snapshot( + shape: str, model: type[PolicyModel], *, item_type: str | None = None, allow_empty: bool = False +) -> StreamEventRule: + """Build one explicit event rule; extraction and coverage remain derived.""" + event_type = shape.split(":", 1)[0] + match = (("type", event_type),) + ((("item.type", item_type),) if item_type else ()) + return StreamEventRule( + shape=shape, + model=model, + role=StreamEventRole.TEXT_SNAPSHOT, + match=match, + text_path=stream_text_path(model), + missing_text_role=StreamEventRole.TEXT_SNAPSHOT if allow_empty else None, + missing_text_value="" if allow_empty else None, + ) + + +STREAM_RULES = ( + StreamEventRule( + shape="response.output_text.delta", + model=models.ResponsesTextDeltaEventProjection, + role=StreamEventRole.GUARDED_TEXT, + match=(("type", "response.output_text.delta"),), + text_path=stream_text_path(models.ResponsesTextDeltaEventProjection), + ), + _snapshot("response.output_text.done", models.ResponsesTextDoneEventProjection), + _snapshot("response.content_part.added", models.ResponsesContentPartAddedEventProjection), + _snapshot("response.content_part.done", models.ResponsesContentPartDoneEventProjection), + _snapshot( + "response.output_item.added:message", + models.ResponsesMessageAddedEventProjection, + item_type="message", + allow_empty=True, + ), + _snapshot( + "response.output_item.done:message", + models.ResponsesMessageDoneEventProjection, + item_type="message", + allow_empty=True, + ), + _snapshot("response.created", models.ResponsesCreatedEventProjection, allow_empty=True), + _snapshot("response.in_progress", models.ResponsesInProgressEventProjection, allow_empty=True), + _snapshot("response.queued", models.ResponsesQueuedEventProjection, allow_empty=True), + _snapshot("response.completed", models.ResponsesCompletedEventProjection, allow_empty=True), + _snapshot("response.failed", models.ResponsesFailedEventProjection, allow_empty=True), + _snapshot("response.incomplete", models.ResponsesIncompleteEventProjection, allow_empty=True), + StreamEventRule( + shape="response.output_item.added:reasoning", + model=models.ResponsesReasoningAddedEventProjection, + role=StreamEventRole.OPAQUE_METADATA, + match=(("type", "response.output_item.added"), ("item.type", "reasoning")), + ), + StreamEventRule( + shape="response.output_item.done:reasoning", + model=models.ResponsesReasoningDoneEventProjection, + role=StreamEventRole.OPAQUE_METADATA, + match=(("type", "response.output_item.done"), ("item.type", "reasoning")), + ), + StreamEventRule( + shape="error", + model=models.ResponsesErrorEventProjection, + role=StreamEventRole.PROVIDER_ERROR, + match=(("type", "error"),), + ), +) + +STREAM_CONTRACT = StreamProjectionContract( + projection_id=PROJECTION_ID, + profile=StreamCapabilityProfile.SINGLE_TEXT_DELTA_V1, + shapes=stream_shape_coverage(STREAM_RULES, sentinels=STREAM_SENTINELS, non_data_shape=STREAM_NON_DATA_SHAPE), +) +STREAM_CLASSIFIER = build_policy_stream_classifier( + StreamClassifierDefinition( + subject=PROJECTION_ID, + contract=STREAM_CONTRACT, + rules=STREAM_RULES, + event_type_field="type", + sentinels=STREAM_SENTINELS, + non_data_shape=STREAM_NON_DATA_SHAPE, + ) +) diff --git a/nemoguardrails/server/experimental/providers/openai/responses/stream_hooks.py b/nemoguardrails/server/experimental/providers/openai/responses/stream_hooks.py new file mode 100644 index 0000000000..ca53ac8df3 --- /dev/null +++ b/nemoguardrails/server/experimental/providers/openai/responses/stream_hooks.py @@ -0,0 +1,96 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Keep Responses completion and stream identity checks outside field policy.""" + +import json + +from nemoguardrails.server.experimental._json_payload import parse_json_object +from nemoguardrails.server.experimental.provider.sse import ServerSentEvent +from nemoguardrails.server.experimental.provider.stream import GuardedStreamEvent, UnsupportedProviderStream + + +class ResponsesStreamHooks: + """Require native completion and one consistent assistant identity. + + The kernel verifies every text snapshot against observed deltas. These hooks + additionally reject post-terminal data and changes to supplied text item IDs, + output positions, or sequence order. State belongs to one upstream stream. + Missing unrelated provider metadata is not synthesized or required. + """ + + def __init__(self) -> None: + self._terminal = False + self._done = False + self._sequence: int | None = None + self._item_id: str | None = None + self._output_index: int | None = None + + def _observe_text_identity(self, item_id: str | None, output_index: int | None) -> None: + """Remember supplied identity and reject switching the guarded target.""" + if item_id is not None: + if self._item_id is not None and item_id != self._item_id: + raise UnsupportedProviderStream("Responses changed the guarded output item ID.") + self._item_id = item_id + if output_index is not None: + if self._output_index is not None and output_index != self._output_index: + raise UnsupportedProviderStream("Responses changed the guarded output index.") + self._output_index = output_index + + def observe_event(self, event: ServerSentEvent, classified: GuardedStreamEvent) -> None: + """Validate protocol state after the classifier has validated the event.""" + if event.data is None: + return + if event.data.strip() == b"[DONE]": + if not self._terminal or self._done: + raise UnsupportedProviderStream("Responses DONE requires a preceding native terminal event.") + self._done = True + return + if self._terminal: + raise UnsupportedProviderStream("Responses emitted data after native completion.") + payload = parse_json_object(event.data) + sequence = payload.get("sequence_number") + if isinstance(sequence, int): + if self._sequence is not None and sequence <= self._sequence: + raise UnsupportedProviderStream("Responses sequence numbers must increase.") + self._sequence = sequence + shape = classified.shape + if shape.startswith(("response.output_text.", "response.content_part.")): + self._observe_text_identity(payload.get("item_id"), payload.get("output_index")) + elif shape.endswith(":message"): + self._observe_text_identity(payload["item"].get("id"), payload.get("output_index")) + elif "response" in payload: + for index, item in enumerate(payload["response"]["output"]): + if item["type"] == "message": + self._observe_text_identity(item.get("id"), index) + if shape in {"response.completed", "response.failed", "response.incomplete", "error"}: + self._terminal = True + + def validate_end_of_stream(self) -> None: + """Reject truncated foreground streams even if they contain DONE.""" + if not self._terminal: + raise UnsupportedProviderStream("Responses ended without a native terminal event.") + + def encode_error(self, rendered_body: bytes) -> tuple[bytes]: + """Render a terminal Responses error event, not a Chat DONE sentinel.""" + details = json.loads(rendered_body)["error"] + payload = { + "type": "error", + "code": details["code"], + "message": details["message"], + "param": details.get("param"), + "sequence_number": (self._sequence + 1 if self._sequence is not None else 0), + } + return (b"event: error\ndata: " + json.dumps(payload, separators=(",", ":")).encode() + b"\n\n",) diff --git a/nemoguardrails/server/experimental/providers/openai/responses/stream_projection.py b/nemoguardrails/server/experimental/providers/openai/responses/stream_projection.py new file mode 100644 index 0000000000..8026a40355 --- /dev/null +++ b/nemoguardrails/server/experimental/providers/openai/responses/stream_projection.py @@ -0,0 +1,202 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Typed Responses SSE shapes; protocol ordering belongs to stream hooks.""" + +from dataclasses import replace +from typing import Annotated, Any, ClassVar, Literal + +from nemoguardrails.server.experimental.provider.payload import GuardedContentModel +from nemoguardrails.server.experimental.provider.projection_policy import ( + ArrayVariant, + ObjectPolicy, + PolicyModel, + constrained, + disabled, + guarded, +) +from nemoguardrails.server.experimental.providers.openai.responses.response_projection import ( + RESPONSE_METADATA_FIELDS, + ResponsesAssistantMessageProjection, + ResponsesOutputTextProjection, + ResponsesReasoningItemProjection, +) + + +class ResponsesSnapshotTextProjection(ResponsesOutputTextProjection): + """An empty initial text snapshot is valid; annotations and logprobs stay restricted.""" + + text: Annotated[str, guarded("assistant", replaceable=False)] + + +class ResponsesSnapshotMessageProjection(ResponsesAssistantMessageProjection): + """Allow an initially empty message, but never a second guarded text part.""" + + policy: ClassVar[ObjectPolicy] = replace( + ResponsesAssistantMessageProjection.policy, + opaque=("phase", "status"), + variant=ArrayVariant((("type", "message"), ("role", "assistant")), "at_most_one"), + ) + id: Annotated[str | None, constrained()] = None + content: Annotated[list[ResponsesSnapshotTextProjection], guarded(max_length=1)] + + +class ResponsesLifecycleResponseProjection(PolicyModel, GuardedContentModel): + """Describe a full response snapshot without demanding a message before generation.""" + + policy: ClassVar[ObjectPolicy] = ObjectPolicy(source="Response", opaque=RESPONSE_METADATA_FIELDS) + output: Annotated[list[ResponsesReasoningItemProjection | ResponsesSnapshotMessageProjection], guarded()] + output_text: Annotated[None, disabled("provider_integrity.duplicate_text")] = None + + +class ResponsesEventProjection(PolicyModel): + """Validate sequence numbers when supplied, without requiring unrelated metadata.""" + + sequence_number: Annotated[int | None, constrained(strict=True, ge=0)] = None + + +class ResponsesIndexedEventProjection(ResponsesEventProjection): + """Track an output position without assuming reasoning always precedes text.""" + + output_index: Annotated[int | None, constrained(strict=True, ge=0)] = None + + +class ResponsesTextEventProjection(ResponsesIndexedEventProjection): + """Restrict content to the single text part and validate any supplied identity.""" + + item_id: Annotated[str | None, constrained()] = None + content_index: Annotated[int | None, constrained(strict=True, ge=0, le=0)] = None + + +class ResponsesTextDeltaEventProjection(ResponsesTextEventProjection): + """Only this event introduces new guarded assistant text.""" + + policy: ClassVar[ObjectPolicy] = ObjectPolicy(source="ResponseTextDeltaEvent", opaque=("obfuscation",)) + type: Annotated[Literal["response.output_text.delta"], constrained()] + delta: Annotated[str, guarded("assistant", replaceable=False)] + logprobs: Annotated[list[Any] | None, constrained(reason="provider_integrity.token_logprobs", max_length=0)] = None + + +class ResponsesTextDoneEventProjection(ResponsesTextEventProjection): + """Repeat the accumulated text rather than introducing a second text source.""" + + policy: ClassVar[ObjectPolicy] = ObjectPolicy(source="ResponseTextDoneEvent") + type: Annotated[Literal["response.output_text.done"], constrained()] + text: Annotated[str, guarded("assistant", replaceable=False)] + logprobs: Annotated[list[Any] | None, constrained(reason="provider_integrity.token_logprobs", max_length=0)] = None + + +class ResponsesContentPartAddedEventProjection(ResponsesTextEventProjection): + """A reviewed text part snapshot, including its initial empty state.""" + + policy: ClassVar[ObjectPolicy] = ObjectPolicy(source="ResponseContentPartAddedEvent") + type: Annotated[Literal["response.content_part.added"], constrained()] + part: Annotated[ResponsesSnapshotTextProjection, guarded()] + + +class ResponsesContentPartDoneEventProjection(ResponsesTextEventProjection): + """The final snapshot of the single text part.""" + + policy: ClassVar[ObjectPolicy] = ObjectPolicy(source="ResponseContentPartDoneEvent") + type: Annotated[Literal["response.content_part.done"], constrained()] + part: Annotated[ResponsesSnapshotTextProjection, guarded()] + + +class ResponsesMessageAddedEventProjection(ResponsesIndexedEventProjection): + """The assistant item is a snapshot, not opaque output.""" + + policy: ClassVar[ObjectPolicy] = ObjectPolicy(source="ResponseOutputItemAddedEvent") + type: Annotated[Literal["response.output_item.added"], constrained()] + item: Annotated[ResponsesSnapshotMessageProjection, guarded()] + + +class ResponsesMessageDoneEventProjection(ResponsesIndexedEventProjection): + """The completed assistant item must agree with previously observed deltas.""" + + policy: ClassVar[ObjectPolicy] = ObjectPolicy(source="ResponseOutputItemDoneEvent") + type: Annotated[Literal["response.output_item.done"], constrained()] + item: Annotated[ResponsesSnapshotMessageProjection, guarded()] + + +class ResponsesReasoningAddedEventProjection(ResponsesIndexedEventProjection): + """Allow only the reviewed, non-plaintext reasoning item.""" + + policy: ClassVar[ObjectPolicy] = ObjectPolicy(source="ResponseOutputItemAddedEvent") + type: Annotated[Literal["response.output_item.added"], constrained()] + item: Annotated[ResponsesReasoningItemProjection, constrained()] + + +class ResponsesReasoningDoneEventProjection(ResponsesIndexedEventProjection): + """Preserve completed reasoning metadata without inspecting encrypted values.""" + + policy: ClassVar[ObjectPolicy] = ObjectPolicy(source="ResponseOutputItemDoneEvent") + type: Annotated[Literal["response.output_item.done"], constrained()] + item: Annotated[ResponsesReasoningItemProjection, constrained()] + + +class ResponsesLifecycleEventProjection(ResponsesEventProjection): + """Share the response snapshot field, not its concrete event identity.""" + + response: Annotated[ResponsesLifecycleResponseProjection, guarded()] + + +class ResponsesCreatedEventProjection(ResponsesLifecycleEventProjection): + """Creation is a nonterminal snapshot.""" + + policy: ClassVar[ObjectPolicy] = ObjectPolicy(source="ResponseCreatedEvent") + type: Annotated[Literal["response.created"], constrained()] + + +class ResponsesInProgressEventProjection(ResponsesLifecycleEventProjection): + """Progress is a nonterminal snapshot.""" + + policy: ClassVar[ObjectPolicy] = ObjectPolicy(source="ResponseInProgressEvent") + type: Annotated[Literal["response.in_progress"], constrained()] + + +class ResponsesQueuedEventProjection(ResponsesLifecycleEventProjection): + """Queue metadata does not make background execution an accepted request mode.""" + + policy: ClassVar[ObjectPolicy] = ObjectPolicy(source="ResponseQueuedEvent") + type: Annotated[Literal["response.queued"], constrained()] + + +class ResponsesCompletedEventProjection(ResponsesLifecycleEventProjection): + """Successful native completion carries a final verified snapshot.""" + + policy: ClassVar[ObjectPolicy] = ObjectPolicy(source="ResponseCompletedEvent") + type: Annotated[Literal["response.completed"], constrained()] + + +class ResponsesFailedEventProjection(ResponsesLifecycleEventProjection): + """Failure may still contain text and therefore remains a checked snapshot.""" + + policy: ClassVar[ObjectPolicy] = ObjectPolicy(source="ResponseFailedEvent") + type: Annotated[Literal["response.failed"], constrained()] + + +class ResponsesIncompleteEventProjection(ResponsesLifecycleEventProjection): + """An incomplete response is terminal, but its partial text is not opaque.""" + + policy: ClassVar[ObjectPolicy] = ObjectPolicy(source="ResponseIncompleteEvent") + type: Annotated[Literal["response.incomplete"], constrained()] + + +class ResponsesErrorEventProjection(ResponsesEventProjection): + """A provider error is terminal diagnostic data, not assistant output.""" + + policy: ClassVar[ObjectPolicy] = ObjectPolicy(source="ResponseErrorEvent", opaque=("code", "param")) + type: Annotated[Literal["error"], constrained()] + message: Annotated[str, constrained()] diff --git a/tests/server/experimental/test_openai_responses_contract_export.py b/tests/server/experimental/test_openai_responses_contract_export.py index 466a950be8..777856719f 100644 --- a/tests/server/experimental/test_openai_responses_contract_export.py +++ b/tests/server/experimental/test_openai_responses_contract_export.py @@ -68,3 +68,24 @@ def test_export_marks_reasoning_as_unselected_and_assistant_as_exactly_one(): variants = export_guard_contract(RESPONSES_ENDPOINT)["response"]["properties"]["output"]["items"]["oneOf"] assert [branch[EXTENSION]["variant"]["cardinality"] for branch in variants] == ["any", "exactly_one"] assert variants[1][EXTENSION]["variant"]["match"] == {"type": "message", "role": "assistant"} + + +def test_stream_export_preserves_all_bound_event_selections_and_missing_text_rules(): + classifier = RESPONSES_ENDPOINT.stream.classifier + exported = export_guard_contract(RESPONSES_ENDPOINT) + events = exported["stream"]["oneOf"] + assert len(events) == len(classifier.rules) + for node, rule in zip(events, classifier.rules): + declaration = node[EXTENSION]["event"] + assert declaration["variants"] == [ + {"source_schema": rule.model.policy.source, "shape": rule.shape, "match": dict(rule.match)} + ] + if rule.missing_text_value == "": + assert declaration["missing_text"] == "empty" + assert exported["stream"][EXTENSION]["transport"] == { + "require_sse_event": False, + "data_discriminator": "type", + "non_data_shape": "keepalive", + "sentinels": {"[DONE]": "done"}, + } + assert "hooks" not in exported["integration"] diff --git a/tests/server/experimental/test_openai_responses_streaming_integration.py b/tests/server/experimental/test_openai_responses_streaming_integration.py new file mode 100644 index 0000000000..0c9304cf51 --- /dev/null +++ b/tests/server/experimental/test_openai_responses_streaming_integration.py @@ -0,0 +1,172 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Exercise Responses through the existing HTTP and streaming kernels.""" + +import httpx +import pytest +from fastapi import FastAPI + +from nemoguardrails.server.experimental._content_checker import ( + ContentAllowed, + ContentBlocked, + ContentInspectionPolicy, + StreamBufferingPolicy, +) +from nemoguardrails.server.experimental._guarded_proxy import create_guarded_http_operation +from nemoguardrails.server.experimental._http_kernel import create_http_proxy_router +from nemoguardrails.server.experimental._streaming_http import StreamingHttpResponse +from nemoguardrails.server.experimental.providers.openai.errors import OPENAI_ERROR_MAPPING +from nemoguardrails.server.experimental.providers.openai.responses.endpoint import RESPONSES_ENDPOINT +from tests.server.experimental.test_openai_chat_streaming_integration import Source, StaticChecker +from tests.server.experimental.test_openai_responses_projections import response_payload +from tests.server.experimental.test_openai_responses_streaming_projections import event_bytes + + +async def post_stream(chunks, checker, *, failure=None): + source = Source(chunks, failure=failure) + dispatches = [] + + async def dispatch(request): + dispatches.append(request) + return StreamingHttpResponse(200, ((b"content-type", b"text/event-stream"),), source) + + async def reject_buffered(request): + raise AssertionError("Streaming request must not use buffered dispatch") + + application = FastAPI() + application.include_router( + create_http_proxy_router( + operations=( + create_guarded_http_operation( + RESPONSES_ENDPOINT, + OPENAI_ERROR_MAPPING, + stream_dispatch=dispatch, + max_stream_event_bytes=1024 * 1024, + max_pending_stream_bytes=1024 * 1024, + ), + ), + checker=checker, + dispatch=reject_buffered, + render_outcome=OPENAI_ERROR_MAPPING.renderer, + ) + ) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=application), base_url="http://proxy.test" + ) as client: + result = await client.post("/v1/responses", json={"model": "test-model", "input": "question", "stream": True}) + return result, source, dispatches + + +def checker(*, input_decision=ContentAllowed(), output_decisions=()): + return StaticChecker( + ContentInspectionPolicy(True, True, StreamBufferingPolicy(8, 0)), input_decision, output_decisions + ) + + +def good_stream(): + return b"".join( + [ + event_bytes("response.created", response={"output": []}), + event_bytes("response.output_text.delta", delta="an", item_id="msg_test", output_index=1), + event_bytes("response.output_text.delta", delta="swer", item_id="msg_test", output_index=1), + event_bytes("response.output_text.done", text="answer"), + event_bytes("response.completed", response=response_payload()), + ] + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("fragment_size", [1, 17, 10000]) +async def test_allowed_stream_preserves_exact_bytes_and_checks_original_deltas(fragment_size): + wire = good_stream() + inspector = checker() + response, source, dispatches = await post_stream( + [wire[index : index + fragment_size] for index in range(0, len(wire), fragment_size)], inspector + ) + assert response.status_code == 200 + assert response.content == wire + assert len(dispatches) == 1 and source.closed + assert [kind for kind, _ in inspector.calls] == ["input", "output"] + assert inspector.calls[0][1].message.content == "question" + assert inspector.calls[1][1].output_content == "answer" + + +@pytest.mark.asyncio +async def test_input_block_prevents_any_upstream_dispatch(): + response, _, dispatches = await post_stream([good_stream()], checker(input_decision=ContentBlocked("blocked"))) + assert response.status_code == 400 + assert dispatches == [] + + +@pytest.mark.asyncio +async def test_output_block_withholds_deltas_and_all_text_snapshots(): + response, source, _ = await post_stream([good_stream()], checker(output_decisions=[ContentBlocked("blocked")])) + assert response.status_code == 200 + assert b'"code":"content_blocked"' in response.content + assert b"event: error" in response.content + assert b"response.output_text" not in response.content + assert b"answer" not in response.content + assert b"[DONE]" not in response.content + assert source.closed + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "tail", + [ + b"", + b"data: [DONE]\n\n", + event_bytes("response.output_text.done", text="different"), + event_bytes("response.refusal.delta", delta="hidden"), + event_bytes("response.completed", response=response_payload("different")), + event_bytes("response.completed", response=response_payload()) + + event_bytes("response.output_text.delta", delta="late"), + ], +) +async def test_invalid_completion_never_releases_pending_text(tail): + wire = event_bytes("response.output_text.delta", delta="answer") + tail + response, source, _ = await post_stream([wire], checker()) + assert b'"code":"unsupported_responses_response_shape"' in response.content + assert b"answer" not in response.content + assert b"hidden" not in response.content + assert source.closed + + +@pytest.mark.asyncio +@pytest.mark.parametrize("terminal", ["response.failed", "response.incomplete", "error"]) +async def test_provider_terminal_failures_are_relayed_after_checked_text(terminal): + fields = ( + {"message": "provider failure", "code": "provider_error"} + if terminal == "error" + else {"response": response_payload()} + ) + wire = event_bytes("response.output_text.delta", delta="answer") + event_bytes(terminal, **fields) + response, source, _ = await post_stream([wire], checker()) + assert response.content == wire + assert source.closed + + +@pytest.mark.asyncio +async def test_upstream_failure_closes_owner_and_withholds_pending_text(): + response, source, _ = await post_stream( + [event_bytes("response.output_text.delta", delta="answer")], + checker(), + failure=httpx.ReadError("private detail"), + ) + assert b"answer" not in response.content + assert b"private detail" not in response.content + assert b"upstream_stream_failed" in response.content + assert source.closed diff --git a/tests/server/experimental/test_openai_responses_streaming_projections.py b/tests/server/experimental/test_openai_responses_streaming_projections.py new file mode 100644 index 0000000000..efbf3eef34 --- /dev/null +++ b/tests/server/experimental/test_openai_responses_streaming_projections.py @@ -0,0 +1,170 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Exercise Responses event policy and per-stream protocol state offline.""" + +import json + +import pytest + +from nemoguardrails.server.experimental.provider.sse import ServerSentEvent +from nemoguardrails.server.experimental.provider.stream import StreamEventRole, UnsupportedProviderStream +from nemoguardrails.server.experimental.providers.openai.responses.endpoint import RESPONSES_ENDPOINT +from nemoguardrails.server.experimental.providers.openai.responses.stream_classifier import STREAM_CLASSIFIER +from tests.server.experimental.test_openai_responses_projections import response_payload + + +def event_bytes(event_type, **fields): + """Serialize synthetic API-shaped events; these are not provider recordings.""" + return ( + b"event: " + + event_type.encode() + + b"\ndata: " + + json.dumps({"type": event_type, **fields}, separators=(",", ":")).encode() + + b"\n\n" + ) + + +def event(event_type, **fields): + return ServerSentEvent.from_bytes(event_bytes(event_type, **fields)) + + +def adapter(): + return RESPONSES_ENDPOINT.stream.create_adapter() + + +@pytest.mark.parametrize( + "native,fields,role,text", + [ + ("response.output_text.delta", {"delta": "answer"}, StreamEventRole.GUARDED_TEXT, "answer"), + ("response.output_text.done", {"text": "answer"}, StreamEventRole.TEXT_SNAPSHOT, "answer"), + ("response.created", {"response": {"output": []}}, StreamEventRole.TEXT_SNAPSHOT, ""), + ("response.completed", {"response": response_payload()}, StreamEventRole.TEXT_SNAPSHOT, "answer"), + ( + "response.output_item.added", + {"item": {"type": "message", "role": "assistant", "content": []}}, + StreamEventRole.TEXT_SNAPSHOT, + "", + ), + ( + "response.output_item.done", + {"item": {"type": "reasoning", "summary": []}}, + StreamEventRole.OPAQUE_METADATA, + None, + ), + ("error", {"message": "failed", "code": "provider_error"}, StreamEventRole.PROVIDER_ERROR, None), + ], +) +def test_classifier_assigns_text_and_metadata_roles(native, fields, role, text): + classified = STREAM_CLASSIFIER.classify_event(event(native, **fields)) + assert classified.role is role + assert classified.text == text + + +@pytest.mark.parametrize( + "native,fields", + [ + ("response.refusal.delta", {"delta": "hidden"}), + ("response.function_call_arguments.delta", {"delta": "hidden"}), + ("response.reasoning_summary_text.delta", {"delta": "hidden"}), + ("response.output_text.annotation.added", {"annotation": {"title": "hidden"}}), + ("response.output_text.delta", {"delta": "answer", "content_index": 1}), + ("response.output_text.delta", {"delta": "answer", "content_index": False}), + ("response.output_text.delta", {"delta": "answer", "output_index": -1}), + ("response.output_text.delta", {"delta": "answer", "sequence_number": "1"}), + ("response.output_text.delta", {"delta": "answer", "logprobs": [{"token": "hidden"}]}), + ("response.output_text.done", {"text": "answer", "logprobs": [{"token": "hidden"}]}), + ( + "response.content_part.done", + {"part": {"type": "output_text", "text": "answer", "annotations": [{"title": "hidden"}]}}, + ), + ("response.output_item.done", {"item": {"type": "reasoning", "summary": [{"text": "hidden"}]}}), + ("response.output_text.delta", {"delta": "answer", "future_text": "hidden"}), + ], +) +def test_unreviewed_content_and_invalid_index_fields_fail_closed(native, fields): + with pytest.raises(UnsupportedProviderStream): + STREAM_CLASSIFIER.classify_event(event(native, **fields)) + + +@pytest.mark.parametrize("terminal", ["response.completed", "response.failed", "response.incomplete", "error"]) +@pytest.mark.parametrize("sentinel", [False, True]) +def test_native_terminal_events_finish_streams_without_requiring_done(terminal, sentinel): + stream = adapter() + fields = {"message": "failed"} if terminal == "error" else {"response": {"output": []}} + stream.classify_event(event(terminal, **fields)) + if sentinel: + stream.classify_event(ServerSentEvent.from_bytes(b"data: [DONE]\n\n")) + stream.classify_event(ServerSentEvent.from_bytes(b": keepalive\n\n")) + stream.validate_end_of_stream() + with pytest.raises(UnsupportedProviderStream): + stream.classify_event(event("response.output_text.delta", delta="late")) + + +def test_done_cannot_replace_native_completion_or_repeat(): + stream = adapter() + done = ServerSentEvent.from_bytes(b"data: [DONE]\n\n") + with pytest.raises(UnsupportedProviderStream): + stream.classify_event(done) + with pytest.raises(UnsupportedProviderStream): + stream.validate_end_of_stream() + stream.classify_event(event("error", message="failed")) + stream.classify_event(done) + with pytest.raises(UnsupportedProviderStream): + stream.classify_event(done) + + +@pytest.mark.parametrize("change", [{"item_id": "other"}, {"output_index": 2}, {"sequence_number": 1}]) +def test_supplied_identity_and_sequence_cannot_change_or_regress(change): + stream = adapter() + stream.classify_event( + event("response.output_text.delta", delta="a", item_id="msg_test", output_index=1, sequence_number=1) + ) + fields = {"delta": "b", "item_id": "msg_test", "output_index": 1, "sequence_number": 2, **change} + with pytest.raises(UnsupportedProviderStream): + stream.classify_event(event("response.output_text.delta", **fields)) + + +def test_lifecycle_snapshot_identity_matches_the_delta_target(): + stream = adapter() + stream.classify_event(event("response.output_text.delta", delta="answer", item_id="different", output_index=1)) + with pytest.raises(UnsupportedProviderStream, match="item ID"): + stream.classify_event(event("response.completed", response=response_payload())) + + +def test_hook_state_is_request_local_and_errors_use_responses_framing(): + first, second = adapter(), adapter() + first.classify_event(event("error", message="failed", sequence_number=4)) + second.classify_event(event("response.output_text.delta", delta="fresh", sequence_number=0)) + with pytest.raises(UnsupportedProviderStream): + second.validate_end_of_stream() + encoded = first.encode_error(b'{"error":{"code":"content_blocked","message":"blocked","param":null}}') + assert len(encoded) == 1 + decoded = ServerSentEvent.from_bytes(encoded[0]) + assert decoded.event_type == b"error" + assert json.loads(decoded.data) == { + "type": "error", + "code": "content_blocked", + "message": "blocked", + "param": None, + "sequence_number": 5, + } + assert b"[DONE]" not in encoded[0] + + +def test_sse_event_and_json_discriminator_must_agree(): + wire = b'event: response.completed\ndata: {"type":"response.output_text.delta","delta":"hidden"}\n\n' + with pytest.raises(UnsupportedProviderStream): + STREAM_CLASSIFIER.classify_event(ServerSentEvent.from_bytes(wire)) From 89da11af4674601a88fe039f0a95faec4bd2e10f Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:46:18 +0200 Subject: [PATCH 04/11] feat(server): compose Chat and Responses through the existing application Add an explicit multi-operation OpenAI factory while preserving the Chat-only factory and dialect behavior. Reuse the existing application assembly, transport ownership, and real regex Rails configuration for buffered and streaming acceptance checks. Verify native stream error decoding with the installed OpenAI SDK offline, and document the guarded subset and inherited lifecycle limitations. Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- .../server/experimental/contracts/README.md | 5 + .../experimental/contracts/openai/README.md | 14 + .../openai/chat_completions/README.md | 12 +- .../experimental/providers/openai/errors.py | 4 +- .../providers/openai/integration.py | 21 +- .../providers/openai/responses/README.md | 134 ++++++++++ .../test_openai_responses_integration.py | 246 ++++++++++++++++++ 7 files changed, 428 insertions(+), 8 deletions(-) create mode 100644 nemoguardrails/server/experimental/providers/openai/responses/README.md create mode 100644 tests/server/experimental/test_openai_responses_integration.py diff --git a/nemoguardrails/server/experimental/contracts/README.md b/nemoguardrails/server/experimental/contracts/README.md index a86bf0be68..dcfa437e03 100644 --- a/nemoguardrails/server/experimental/contracts/README.md +++ b/nemoguardrails/server/experimental/contracts/README.md @@ -88,6 +88,11 @@ Provider-required opaque fields may be left to provider validation; a projection Object `variant` metadata describes discriminator matching and selected-variant cardinality. This is distinct from ordinary array length constraints. +Typed `ObjectPolicy.variant` declarations derive the runtime array selectors; +required literal discriminators must distinguish the reviewed object branches. +For example, Responses can retain reasoning items while requiring exactly one +assistant message. Generic JSON Schema validators ignore this guard annotation; +they do not enforce selected-variant cardinality without policy-aware handling. `stream_selector_field` names a boolean request field: true selects streaming; false or null selects buffered output, with null allowed only when the field's schema permits it. Omission follows the declared default. This marker does not diff --git a/nemoguardrails/server/experimental/contracts/openai/README.md b/nemoguardrails/server/experimental/contracts/openai/README.md index 6d957acff3..6c25dfc590 100644 --- a/nemoguardrails/server/experimental/contracts/openai/README.md +++ b/nemoguardrails/server/experimental/contracts/openai/README.md @@ -51,6 +51,20 @@ arbitrary Python validation. For example, the runtime rejects choice index [export guide](../../providers/openai/chat_completions/README.md#contract-export) for generation, drift checks, and limitations. +## Responses + +The [Responses boundary](../../providers/openai/responses/README.md) supports one +foreground input string, one assistant output message alongside reviewed +reasoning metadata, and buffered or SSE output. Python models, derived bindings, +event rules, and handwritten protocol hooks are authoritative. Its +[exported contract](_generated/responses.guard.yaml) records payload policy and +the bound stream classifier, including heterogeneous-array selection; hooks +remain runtime code rather than serialized policy. + +Use `create_openai_integration` to guard both operations. The existing +`create_openai_chat_integration` factory remains Chat-only. Neither factory +establishes compatibility with every server exposing an OpenAI-shaped API. + ## Provider provenance [source.yaml](source.yaml) records the OpenAPI document's immutable revision, diff --git a/nemoguardrails/server/experimental/providers/openai/chat_completions/README.md b/nemoguardrails/server/experimental/providers/openai/chat_completions/README.md index b2137ea6d1..45ffb90d11 100644 --- a/nemoguardrails/server/experimental/providers/openai/chat_completions/README.md +++ b/nemoguardrails/server/experimental/providers/openai/chat_completions/README.md @@ -81,11 +81,13 @@ constraints, independent of the inspection document's supported vocabulary. Unsupported document representations fail at export, not runtime construction. The shared [stream policy helpers](../../../provider/stream_policy.py) derive a text path -from typed fields, without schema generation, only when there is one subject -and every traversed array has at most one item. A null container or null text -on that path is missing text. Union containers and multi-item selectors remain -explicit event-rule paths. Buffered extraction still requires exactly one item; -the streaming helper does not relax it. +from typed fields, without schema generation, when there is one subject and +arrays are bounded to at most one item or declare a reviewed, bounded +`ObjectPolicy.variant` selection. Chat uses the simple bounded-array path; +Responses uses heterogeneous variants. A null container or null text on that +path is missing text. Unannotated unions remain unsupported by typed derivation. +Buffered extraction still requires exactly one selected item; the streaming +helper does not relax it. The typed classifier builder checks the text path, assistant subject, event roles, and coverage against these declarations before runtime use. A mismatched path diff --git a/nemoguardrails/server/experimental/providers/openai/errors.py b/nemoguardrails/server/experimental/providers/openai/errors.py index 86f9ac18b8..e20dacfc3b 100644 --- a/nemoguardrails/server/experimental/providers/openai/errors.py +++ b/nemoguardrails/server/experimental/providers/openai/errors.py @@ -172,14 +172,14 @@ def render_openai_error( if isinstance(outcome, StreamUpstreamFailed): return _error_response( 502, - "The upstream OpenAI Chat Completions stream failed.", + "The upstream OpenAI stream failed.", "proxy_error", "upstream_stream_failed", ) if isinstance(outcome, StreamProcessingFailed): return _error_response( 502, - "The OpenAI Chat Completions stream could not be processed safely.", + "The OpenAI stream could not be processed safely.", "proxy_error", "stream_processing_failed", ) diff --git a/nemoguardrails/server/experimental/providers/openai/integration.py b/nemoguardrails/server/experimental/providers/openai/integration.py index 3b106a1e5f..658af1d06e 100644 --- a/nemoguardrails/server/experimental/providers/openai/integration.py +++ b/nemoguardrails/server/experimental/providers/openai/integration.py @@ -13,10 +13,11 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Compose the buffered OpenAI Chat route and provider-native errors.""" +"""Compose explicit OpenAI operation sets and provider-native errors.""" import os from collections.abc import Collection, Mapping +from dataclasses import replace from fastapi import APIRouter @@ -35,6 +36,7 @@ CHAT_COMPLETIONS_ENDPOINT, ) from nemoguardrails.server.experimental.providers.openai.errors import OPENAI_ERROR_MAPPING +from nemoguardrails.server.experimental.providers.openai.responses.endpoint import RESPONSES_ENDPOINT def create_openai_chat_integration( @@ -66,6 +68,23 @@ def create_openai_chat_integration( ) +def create_openai_integration( + *, + instance_id: str = "openai", + base_url: str | None = None, + mount_path: str = "", +) -> ProviderIntegration: + """Guard both Chat Completions and Responses on one OpenAI instance. + + This is an explicit opt-in to a second guarded operation, not an alias of + the Chat-only factory. Other routes retain the application's passthrough + behavior. A compatible upstream URL does not establish dialect support for + either operation; this factory selects the strict OpenAI boundaries. + """ + chat = create_openai_chat_integration(instance_id=instance_id, base_url=base_url, mount_path=mount_path) + return replace(chat, endpoints=(*chat.endpoints, RESPONSES_ENDPOINT)) + + def create_openai_chat_router( *, checker: ContentChecker, diff --git a/nemoguardrails/server/experimental/providers/openai/responses/README.md b/nemoguardrails/server/experimental/providers/openai/responses/README.md new file mode 100644 index 0000000000..0c64c639eb --- /dev/null +++ b/nemoguardrails/server/experimental/providers/openai/responses/README.md @@ -0,0 +1,134 @@ +# OpenAI Responses boundary + +This experimental integration guards foreground `POST /v1/responses` in +buffered and SSE modes. Typed Python declarations are authoritative. The +[exported contract](../../../contracts/openai/_generated/responses.guard.yaml) +documents the actual bound policy; runtime construction never reads it. + +## Supported content + +| Direction | Guarded subject | Restrictions | +| --- | --- | --- | +| Request | One nonempty `input` string | No input-item arrays, instructions, prompt templates, conversation references, tools, or background execution. | +| Buffered response | One assistant message's single nonempty `output_text.text` | Reasoning items may occur before or after it, but may not contain plaintext reasoning or summaries. | +| Streaming response | `response.output_text.delta.delta` | Reviewed text snapshots must equal the accumulated deltas; only one assistant target is supported. | + +The `single_text.v1` and `single_text_delta.v1` profiles are framework +capabilities, not user-selected rail configurations. Replacement is unsupported +for Responses in both directions. A replacement decision fails explicitly. + +Request `stream` is a strict boolean, defaulting to false; this boundary rejects +null rather than coercing it. `background` permits only the boolean false. +Disabled fields accept omission or null, not an empty container or a value such +as `tool_choice: none`. `top_logprobs` permits omission, null, or integer zero. +Response and event logprobs and annotations must be absent, null, or empty: +token alternatives and citation text are not separately inspected. + +Reviewed provider metadata, including encrypted reasoning, usage, IDs, and +echoed controls, remains opaque where declared. Opaque does not mean safe or +inspected. Unknown root fields fail closed. Only nested objects explicitly +marked configurable can accept unknown members under a trusted `ALLOW` policy; +the stream classifier always uses the closed default. Case aliases cannot +override reviewed policy. + +## Declaration and runtime responsibilities + +- `request_projection.py` and `response_projection.py` classify fields. +- The binding modules derive coverage and text locations from those declarations. +- `ObjectPolicy.variant` declares the required discriminator and selected-array + cardinality. Buffered output requires exactly one assistant message; lifecycle + snapshots allow at most one, including initially empty output. +- `stream_projection.py` describes the reviewed event bodies. +- `stream_classifier.py` declares event selection, roles, and missing-text + handling. Text paths and shape coverage are derived rather than handwritten. +- `stream_hooks.py` owns per-request protocol state and native error framing. +- `endpoint.py` composes the payload bindings and explicit `StreamBinding`. + +The original payload and SSE bytes are forwarded, not reconstructed from +Pydantic models. Projection defaults are never inserted into provider traffic. + +## Streaming behavior and limits + +Creation, progress, queue, message, content-part, text-done, and terminal +response events are text snapshots. Initially missing message text is treated +as an empty snapshot only where the event rule explicitly permits it. Reasoning +item events are validated metadata; plaintext reasoning events and unknown +content-producing families fail closed. + +A foreground stream must end with `response.completed`, `response.failed`, +`response.incomplete`, or `error`. An optional `[DONE]` may follow native +completion once; it cannot replace it. Data-less keepalives are not terminal. +Post-terminal payload and duplicate completion are rejected. + +When supplied, sequence numbers must increase and text item IDs and output +indices must remain consistent, including with lifecycle snapshots. Content +index is restricted to zero. Unrelated metadata is not required, so this is +not a full validator of every provider-required field or lifecycle transition. +SSE event names, when present, must match the JSON `type` discriminator. + +The existing kernel bounds event size, pending bytes, and snapshot history. +Snapshots whose history can no longer be verified fail closed. Checked windows +may already have reached the client before a later protocol failure; the proxy +cannot retract them. Blocking withholds pending text and snapshots. Proxy stream +failures use a native `event: error` body with `type: error`, not a Chat `[DONE]` +frame. Provider terminal failures retain their original bytes after any pending +assistant text has been checked. + +## Application composition + +Use `create_openai_integration` to opt into guarding both Chat Completions and +Responses. `create_openai_chat_integration` remains Chat-only, including its +existing explicit dialect selection. The broader factory does not infer a +Responses dialect from a compatible upstream URL. + +```python +from pathlib import Path + +from nemoguardrails.server.experimental._application import create_proxy_application +from nemoguardrails.server.experimental.providers.openai.integration import create_openai_integration +from nemoguardrails.server.experimental.rails import ConfigEntry + +app = create_proxy_application( + providers=(create_openai_integration(base_url="https://api.openai.com/v1"),), + rails_configs=(ConfigEntry("policy", Path("/trusted/path/to/rails")),), +) +``` + +This uses the existing application assembly and trusted Rails configuration +directory, not a new launcher or configuration format. Server dependencies use +the project's `server` extra. Provider generation is forwarded directly over +HTTP, not delegated to a LangChain model. The caller supplies upstream +authorization; any configured detectors have their own configuration and +credential requirements. Other operations retain passthrough behavior and are +not thereby guarded. Deployment authentication and socket-level acceptance +belong to the application/example work, not these declarations. + +## Contract export and verification + +```bash +uv run --locked python -m nemoguardrails.server.experimental.provider.contract_export \ + nemoguardrails.server.experimental.providers.openai.responses.endpoint:RESPONSES_ENDPOINT \ + --output nemoguardrails/server/experimental/contracts/openai/_generated/responses.guard.yaml +``` + +Use `--check` instead of `--output` to detect drift without writing. Only import +trusted endpoint references. The export includes event rules and array-variant +selection, but does not serialize hook state, lifecycle behavior, or transport +implementation. Generic JSON Schema validators ignore guard annotations such +as selected-variant cardinality and case-alias rejection; policy-aware checks +are needed for those rules. Python strict integer checks are also stronger than +JSON Schema's mathematical integer semantics. + +The [OpenAI source pin](../../../contracts/openai/source.yaml) remains the review +baseline. The old Responses implementation provided characterization cases, +not authoritative generated models. The +[official streaming guide](https://developers.openai.com/api/docs/guides/streaming-responses) +describes semantic SSE events; this integration intentionally supports only the +reviewed subset above. Tests use synthetic API-shaped payloads, not recordings +or a claim of compatibility with every OpenAI-compatible server. + +Focused tests cover projections, export parity, protocol behavior, fragmented +SSE, blocked output, and application composition with real offline regex Rails +and HTTPX ownership. Healthy cleanup is exercised, but the separate core/shared +HTTP interrupted-cleanup defects remain prerequisites for complete lifecycle +acceptance. This feature does not fix or suppress those regressions. diff --git a/tests/server/experimental/test_openai_responses_integration.py b/tests/server/experimental/test_openai_responses_integration.py new file mode 100644 index 0000000000..9da240749b --- /dev/null +++ b/tests/server/experimental/test_openai_responses_integration.py @@ -0,0 +1,246 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Exercise explicit OpenAI composition through real Rails and HTTPX ownership.""" + +import json + +import httpx +import pytest +from fastapi.testclient import TestClient + +from nemoguardrails.server.experimental._application import create_proxy_application +from nemoguardrails.server.experimental.providers.openai.chat_completions.endpoint import CHAT_COMPLETIONS_ENDPOINT +from nemoguardrails.server.experimental.providers.openai.integration import ( + create_openai_chat_integration, + create_openai_integration, +) +from nemoguardrails.server.experimental.providers.openai.responses.endpoint import RESPONSES_ENDPOINT +from nemoguardrails.server.experimental.rails import ConfigEntry +from tests.server.experimental.test_openai_responses_projections import response_payload +from tests.server.experimental.test_openai_responses_streaming_projections import event_bytes + + +class ResponseBody(httpx.AsyncByteStream): + """Record ownership of synthetic provider bytes without a network service.""" + + def __init__(self, body): + self.body = body + self.closed = False + + async def __aiter__(self): + for index in range(0, len(self.body), 19): + yield self.body[index : index + 19] + + async def aclose(self): + self.closed = True + + +@pytest.fixture +def proxy(tmp_path): + config = tmp_path / "rails" + config.mkdir() + (config / "config.yml").write_text("""models: [] +rails: + config: + regex_detection: + input: + patterns: [BLOCK_INPUT] + output: + patterns: [BLOCK_OUTPUT] + input: + flows: [regex check input] + output: + flows: [regex check output] + streaming: + enabled: true + chunk_size: 8 + context_size: 0 + stream_first: false +""") + requests = [] + bodies = [] + settings = {"output": "answer", "status": 200} + + async def handler(request): + requests.append(request) + payload = json.loads(await request.aread()) + text = settings["output"] + streaming = payload.get("stream") is True and settings["status"] == 200 + if settings["status"] != 200: + wire = b'{ "error": { "message": "native upstream failure", "code": "rate_limit" } }' + elif request.url.path.endswith("/chat/completions"): + if streaming: + wire = ( + b"data: " + + json.dumps( + {"object": "chat.completion.chunk", "choices": [{"index": 0, "delta": {"content": text}}]} + ).encode() + + b"\n\ndata: [DONE]\n\n" + ) + else: + wire = json.dumps({"choices": [{"message": {"role": "assistant", "content": text}}]}).encode() + elif streaming: + split = max(1, len(text) // 2) + wire = b"".join( + [ + event_bytes("response.created", response={"output": []}, sequence_number=0), + event_bytes( + "response.output_text.delta", + delta=text[:split], + item_id="msg_test", + output_index=1, + content_index=0, + sequence_number=1, + ), + event_bytes( + "response.output_text.delta", + delta=text[split:], + item_id="msg_test", + output_index=1, + content_index=0, + sequence_number=2, + ), + event_bytes("response.completed", response=response_payload(text), sequence_number=3), + ] + ) + else: + wire = json.dumps(response_payload(text), indent=2).encode() + body = ResponseBody(wire) + bodies.append(body) + return httpx.Response( + settings["status"], + headers={ + "content-type": "text/event-stream" if streaming else "application/json", + "x-request-id": "synthetic", + }, + stream=body, + ) + + provider = create_openai_integration(instance_id="test", base_url="https://provider.test/v1", mount_path="/openai") + app = create_proxy_application( + providers=(provider,), + rails_configs=(ConfigEntry("regex", config),), + http_transport_factory=lambda: httpx.MockTransport(handler), + ) + with TestClient(app) as client: + yield client, requests, bodies, settings + assert all(body.closed for body in bodies) + + +def test_factories_keep_chat_only_and_multi_operation_policies_distinct(): + chat = create_openai_chat_integration(base_url="https://provider.test/v1") + both = create_openai_integration(base_url="https://provider.test/v1") + assert chat.endpoints == (CHAT_COMPLETIONS_ENDPOINT,) + assert both.endpoints == (CHAT_COMPLETIONS_ENDPOINT, RESPONSES_ENDPOINT) + assert chat.upstream == both.upstream + assert chat.errors is both.errors + + +@pytest.mark.parametrize("streaming", [False, True]) +@pytest.mark.parametrize("operation", ["responses", "chat/completions"]) +def test_both_operations_work_through_the_existing_application(proxy, streaming, operation): + client, requests, bodies, _ = proxy + subject = ( + {"input": "question"} if operation == "responses" else {"messages": [{"role": "user", "content": "question"}]} + ) + body = json.dumps({"model": "test-model", "stream": streaming, **subject}, indent=2).encode() + response = client.post( + "/openai/v1/" + operation, + content=body, + headers={"content-type": "application/json", "authorization": "Bearer synthetic"}, + ) + assert response.status_code == 200 + assert response.content == bodies[0].body + assert str(requests[0].url) == "https://provider.test/v1/" + operation + assert requests[0].content == body + assert requests[0].headers["authorization"] == "Bearer synthetic" + + +@pytest.mark.parametrize("streaming", [False, True]) +def test_real_input_rail_blocks_before_dispatch(proxy, streaming): + client, requests, _, _ = proxy + response = client.post("/openai/v1/responses", json={"input": "BLOCK_INPUT", "stream": streaming}) + assert response.status_code == 400 + assert response.json()["error"]["code"] == "content_blocked" + assert not requests + + +@pytest.mark.parametrize("streaming", [False, True]) +def test_real_output_rail_withholds_blocked_response_and_stream_snapshots(proxy, streaming): + client, _, bodies, settings = proxy + settings["output"] = "BLOCK_OUTPUT" + response = client.post("/openai/v1/responses", json={"input": "question", "stream": streaming}) + assert response.status_code == (200 if streaming else 400) + assert b"content_blocked" in response.content + assert b"BLOCK_OUTPUT" not in response.content + if streaming: + assert b"response.output_text.delta" not in response.content + assert b"event: error" in response.content + assert bodies[0].closed + + +@pytest.mark.parametrize("streaming", [False, True]) +def test_provider_failure_bytes_and_headers_are_not_rewritten(proxy, streaming): + client, _, bodies, settings = proxy + settings["status"] = 429 + response = client.post("/openai/v1/responses", json={"input": "question", "stream": streaming}) + assert response.status_code == 429 + assert response.content == bodies[0].body + assert response.headers["x-request-id"] == "synthetic" + + +@pytest.mark.parametrize("path", ["/openai/v2/responses", "/openai/v1/responses/"]) +def test_noncanonical_responses_routes_cannot_bypass_inspection(proxy, path): + client, requests, _, _ = proxy + response = client.post(path, json={"input": "BLOCK_INPUT"}, follow_redirects=False) + assert response.status_code in {400, 404, 405, 422} + assert not requests + + +def test_application_openapi_documents_both_operations_without_loading_export(proxy): + client, _, _, _ = proxy + paths = client.get("/openapi.json").json()["paths"] + assert "/openai/v1/responses" in paths + assert "/openai/v1/chat/completions" in paths + + +@pytest.mark.parametrize("blocked", [False, True]) +def test_openai_sdk_decodes_native_success_and_proxy_error_events(proxy, blocked): + from openai import OpenAI + + client, _, _, settings = proxy + if blocked: + settings["output"] = "BLOCK_OUTPUT" + + def forward(request): + response = client.post( + "/openai/v1/responses", content=request.content, headers={"content-type": "application/json"} + ) + return httpx.Response(response.status_code, headers=response.headers, content=response.content) + + with OpenAI( + api_key="synthetic", + base_url="https://sdk.test/v1", + http_client=httpx.Client(transport=httpx.MockTransport(forward)), + ) as sdk: + events = list(sdk.responses.create(model="test-model", input="question", stream=True)) + if blocked: + assert events[-1].type == "error" + assert events[-1].code == "content_blocked" + assert not any(item.type == "response.output_text.delta" for item in events) + else: + assert events[-1].type == "response.completed" + assert "".join(item.delta for item in events if item.type == "response.output_text.delta") == "answer" From e8b510175329eadfb9cbd931a605964dd0aeee92 Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:32:12 +0200 Subject: [PATCH 05/11] feat(server): select OpenAI operations through the integration factory Accept typed operation names for Chat Completions, Responses, or both. Keep the existing fixed two-operation default and reject unknown, duplicate, empty, or malformed selections before constructing an integration. Delegate the Chat-only factory to the shared assembly while preserving its explicit dialect endpoint customization and upstream settings. Document that selection installs guard bindings, not a route allowlist; unselected routes retain passthrough behavior. Validate selected versus unselected input/output behavior with real regex Rails and HTTPX owners in buffered and streaming modes. Validation: 730 affected offline tests and pre-commit passed. Core lifecycle prerequisites remain separate. Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- .../experimental/contracts/openai/README.md | 5 +- .../providers/openai/integration.py | 64 +++++++++--- .../providers/openai/responses/README.md | 31 ++++-- .../test_openai_responses_integration.py | 99 ++++++++++++++++++- 4 files changed, 173 insertions(+), 26 deletions(-) diff --git a/nemoguardrails/server/experimental/contracts/openai/README.md b/nemoguardrails/server/experimental/contracts/openai/README.md index 6c25dfc590..b509eafed3 100644 --- a/nemoguardrails/server/experimental/contracts/openai/README.md +++ b/nemoguardrails/server/experimental/contracts/openai/README.md @@ -61,7 +61,10 @@ event rules, and handwritten protocol hooks are authoritative. Its the bound stream classifier, including heterogeneous-array selection; hooks remain runtime code rather than serialized policy. -Use `create_openai_integration` to guard both operations. The existing +Use `create_openai_integration(operations=("chat_completions", "responses"))` +to guard both operations, or select either name alone. The default remains +both; selection installs guard bindings rather than disabling other routes. +Unselected routes retain the application's passthrough behavior. The existing `create_openai_chat_integration` factory remains Chat-only. Neither factory establishes compatibility with every server exposing an OpenAI-shaped API. diff --git a/nemoguardrails/server/experimental/providers/openai/integration.py b/nemoguardrails/server/experimental/providers/openai/integration.py index 658af1d06e..efb380006f 100644 --- a/nemoguardrails/server/experimental/providers/openai/integration.py +++ b/nemoguardrails/server/experimental/providers/openai/integration.py @@ -16,8 +16,9 @@ """Compose explicit OpenAI operation sets and provider-native errors.""" import os -from collections.abc import Collection, Mapping +from collections.abc import Collection, Mapping, Sequence from dataclasses import replace +from typing import Literal from fastapi import APIRouter @@ -38,21 +39,48 @@ from nemoguardrails.server.experimental.providers.openai.errors import OPENAI_ERROR_MAPPING from nemoguardrails.server.experimental.providers.openai.responses.endpoint import RESPONSES_ENDPOINT +OpenAIOperation = Literal["chat_completions", "responses"] +"""Built-in operation names accepted by the OpenAI integration factory.""" -def create_openai_chat_integration( + +def create_openai_integration( *, instance_id: str = "openai", base_url: str | None = None, mount_path: str = "", - endpoint: GuardedJsonEndpoint | None = None, + operations: Sequence[OpenAIOperation] = ("chat_completions", "responses"), ) -> ProviderIntegration: - """Create an OpenAI-protocol instance with an explicitly reviewed endpoint. + """Create an OpenAI instance with the selected built-in guard bindings. + + ``operations`` is a nonempty sequence of unique names, in binding order. + The fixed default selects Chat Completions and Responses; it does not mean + all operations a future version might support. A bare string is invalid. + + Selection installs guard bindings, not a route allowlist. Unselected routes + retain the application's passthrough behavior and are not thereby guarded. + These are strict OpenAI boundaries: an upstream URL does not select a + dialect. Trusted applications can customize explicit endpoint bindings. - Omitting ``endpoint`` retains the strict OpenAI Chat boundary. A trusted - application may instead supply an OpenAI-wire-compatible dialect endpoint; - it must use the OpenAI error mapping. Other instances are unaffected. + Raises: + TypeError: ``operations`` is not a sequence of operation names. + ValueError: The selection is empty, unknown, or contains duplicates. """ + if isinstance(operations, (str, bytes)) or not isinstance(operations, Sequence): + raise TypeError("operations must be a sequence of operation names, not a bare string") + selected = tuple(operations) + if not selected: + raise ValueError("operations must select at least one OpenAI operation") + endpoints: dict[OpenAIOperation, GuardedJsonEndpoint] = { + "chat_completions": CHAT_COMPLETIONS_ENDPOINT, + "responses": RESPONSES_ENDPOINT, + } + for operation in selected: + if not isinstance(operation, str) or operation not in endpoints: + raise ValueError(f"Unsupported OpenAI operation: {operation!r}; expected chat_completions or responses") + if len(set(selected)) != len(selected): + raise ValueError("operations must not contain duplicate names") + resolved_base_url = ( base_url if base_url is not None @@ -63,26 +91,30 @@ def create_openai_chat_integration( instance_id=instance_id, upstream=UpstreamSettings(base_url=resolved_base_url, mount_path=mount_path), display_name="OpenAI", - endpoints=(CHAT_COMPLETIONS_ENDPOINT if endpoint is None else endpoint,), + endpoints=tuple(endpoints[operation] for operation in selected), errors=OPENAI_ERROR_MAPPING, ) -def create_openai_integration( +def create_openai_chat_integration( *, instance_id: str = "openai", base_url: str | None = None, mount_path: str = "", + endpoint: GuardedJsonEndpoint | None = None, ) -> ProviderIntegration: - """Guard both Chat Completions and Responses on one OpenAI instance. + """Create a Chat-only OpenAI instance with an explicitly reviewed endpoint. - This is an explicit opt-in to a second guarded operation, not an alias of - the Chat-only factory. Other routes retain the application's passthrough - behavior. A compatible upstream URL does not establish dialect support for - either operation; this factory selects the strict OpenAI boundaries. + Omitting ``endpoint`` selects the strict OpenAI Chat boundary through + :func:`create_openai_integration`. A trusted application may instead supply + an OpenAI-wire-compatible dialect endpoint; it must use the OpenAI error + mapping. Other instances are unaffected. Unselected routes retain the + application's passthrough behavior. """ - chat = create_openai_chat_integration(instance_id=instance_id, base_url=base_url, mount_path=mount_path) - return replace(chat, endpoints=(*chat.endpoints, RESPONSES_ENDPOINT)) + integration = create_openai_integration( + instance_id=instance_id, base_url=base_url, mount_path=mount_path, operations=("chat_completions",) + ) + return integration if endpoint is None else replace(integration, endpoints=(endpoint,)) def create_openai_chat_router( diff --git a/nemoguardrails/server/experimental/providers/openai/responses/README.md b/nemoguardrails/server/experimental/providers/openai/responses/README.md index 0c64c639eb..78db78f352 100644 --- a/nemoguardrails/server/experimental/providers/openai/responses/README.md +++ b/nemoguardrails/server/experimental/providers/openai/responses/README.md @@ -76,10 +76,16 @@ assistant text has been checked. ## Application composition -Use `create_openai_integration` to opt into guarding both Chat Completions and -Responses. `create_openai_chat_integration` remains Chat-only, including its -existing explicit dialect selection. The broader factory does not infer a -Responses dialect from a compatible upstream URL. +Use `create_openai_integration` to select which built-in operations to guard. +Its `operations` argument accepts a nonempty sequence of unique typed names: +`"chat_completions"`, `"responses"`, or both. The fixed default is +`("chat_completions", "responses")`, not all operations supported by future +versions. Unknown names, duplicate names, empty selections, and bare strings +are rejected during construction. + +`create_openai_chat_integration` remains a Chat-only wrapper, including its +existing explicit dialect endpoint customization. The broader factory selects +strict OpenAI boundaries; it does not infer a dialect from an upstream URL. ```python from pathlib import Path @@ -89,7 +95,12 @@ from nemoguardrails.server.experimental.providers.openai.integration import crea from nemoguardrails.server.experimental.rails import ConfigEntry app = create_proxy_application( - providers=(create_openai_integration(base_url="https://api.openai.com/v1"),), + providers=( + create_openai_integration( + base_url="https://api.openai.com/v1", + operations=("chat_completions", "responses"), + ), + ), rails_configs=(ConfigEntry("policy", Path("/trusted/path/to/rails")),), ) ``` @@ -99,8 +110,14 @@ directory, not a new launcher or configuration format. Server dependencies use the project's `server` extra. Provider generation is forwarded directly over HTTP, not delegated to a LangChain model. The caller supplies upstream authorization; any configured detectors have their own configuration and -credential requirements. Other operations retain passthrough behavior and are -not thereby guarded. Deployment authentication and socket-level acceptance +credential requirements. + +For Responses-only guarding, pass `operations=("responses",)`. This selects +guard bindings, not a route allowlist: Chat Completions and other unselected +routes retain the application's passthrough behavior without inspection. +Disabling those routes is a separate deployment policy. + +Deployment authentication and socket-level acceptance belong to the application/example work, not these declarations. ## Contract export and verification diff --git a/tests/server/experimental/test_openai_responses_integration.py b/tests/server/experimental/test_openai_responses_integration.py index 9da240749b..51f609d951 100644 --- a/tests/server/experimental/test_openai_responses_integration.py +++ b/tests/server/experimental/test_openai_responses_integration.py @@ -23,6 +23,9 @@ from nemoguardrails.server.experimental._application import create_proxy_application from nemoguardrails.server.experimental.providers.openai.chat_completions.endpoint import CHAT_COMPLETIONS_ENDPOINT +from nemoguardrails.server.experimental.providers.openai.dialects.litellm.chat_completions import ( + LITELLM_CHAT_COMPLETIONS_ENDPOINT, +) from nemoguardrails.server.experimental.providers.openai.integration import ( create_openai_chat_integration, create_openai_integration, @@ -49,7 +52,7 @@ async def aclose(self): @pytest.fixture -def proxy(tmp_path): +def proxy(tmp_path, request): config = tmp_path / "rails" config.mkdir() (config / "config.yml").write_text("""models: [] @@ -129,7 +132,12 @@ async def handler(request): stream=body, ) - provider = create_openai_integration(instance_id="test", base_url="https://provider.test/v1", mount_path="/openai") + provider = create_openai_integration( + instance_id="test", + base_url="https://provider.test/v1", + mount_path="/openai", + operations=getattr(request, "param", ("chat_completions", "responses")), + ) app = create_proxy_application( providers=(provider,), rails_configs=(ConfigEntry("regex", config),), @@ -149,6 +157,93 @@ def test_factories_keep_chat_only_and_multi_operation_policies_distinct(): assert chat.errors is both.errors +@pytest.mark.parametrize( + ("operations", "endpoints"), + [ + (("chat_completions",), (CHAT_COMPLETIONS_ENDPOINT,)), + (("responses",), (RESPONSES_ENDPOINT,)), + (("chat_completions", "responses"), (CHAT_COMPLETIONS_ENDPOINT, RESPONSES_ENDPOINT)), + (["responses", "chat_completions"], (RESPONSES_ENDPOINT, CHAT_COMPLETIONS_ENDPOINT)), + ], +) +def test_factory_selects_exact_bindings_in_declared_order(operations, endpoints): + integration = create_openai_integration( + operations=operations, instance_id="selected", base_url="https://provider.test/v1", mount_path="/selected" + ) + assert integration.endpoints == endpoints + assert integration.instance_id == "selected" + assert integration.upstream.base_url == "https://provider.test/v1" + assert integration.upstream.mount_path == "/selected" + + +@pytest.mark.parametrize( + "operations", + [(), [], ("unknown",), ("responses", "unknown"), ("responses", "responses"), (None,), (["responses"],)], +) +def test_factory_rejects_invalid_operation_names_and_selections(operations): + with pytest.raises(ValueError): + create_openai_integration(operations=operations) + + +@pytest.mark.parametrize("operations", ["responses", b"responses", None, {"responses"}, {"responses": True}]) +def test_factory_requires_a_sequence_not_a_bare_name_or_mapping(operations): + with pytest.raises(TypeError, match="operations must be a sequence"): + create_openai_integration(operations=operations) + + +def test_factory_snapshots_the_operation_selection(): + operations = ["responses"] + integration = create_openai_integration(operations=operations) + operations.append("chat_completions") + assert integration.endpoints == (RESPONSES_ENDPOINT,) + + +def test_chat_wrapper_preserves_settings_and_explicit_dialect(monkeypatch): + monkeypatch.setenv("NEMO_GUARDRAILS_OPENAI_PROXY_UPSTREAM_BASE_URL", "https://gateway.test/v1") + chat = create_openai_chat_integration(instance_id="chat", mount_path="/chat") + selected = create_openai_integration(instance_id="chat", mount_path="/chat", operations=("chat_completions",)) + dialect = create_openai_chat_integration( + instance_id="chat", mount_path="/chat", endpoint=LITELLM_CHAT_COMPLETIONS_ENDPOINT + ) + assert chat == selected + assert chat.upstream.base_url == "https://gateway.test/v1" + assert dialect.endpoints == (LITELLM_CHAT_COMPLETIONS_ENDPOINT,) + assert dialect.upstream == chat.upstream + assert dialect.errors is chat.errors + assert create_openai_integration().endpoints == (CHAT_COMPLETIONS_ENDPOINT, RESPONSES_ENDPOINT) + + +@pytest.mark.parametrize( + ("proxy", "selected"), + [(("responses",), "responses"), (("chat_completions",), "chat/completions")], + indirect=["proxy"], +) +@pytest.mark.parametrize("streaming", [False, True]) +@pytest.mark.parametrize("operation", ["responses", "chat/completions"]) +@pytest.mark.parametrize("blocked_stage", ["input", "output"]) +def test_operation_selection_guards_selected_routes_and_preserves_other_passthrough( + proxy, selected, streaming, operation, blocked_stage +): + client, requests, bodies, settings = proxy + guarded = operation == selected + text = "BLOCK_INPUT" if blocked_stage == "input" else "question" + if blocked_stage == "output": + settings["output"] = "BLOCK_OUTPUT" + subject = {"input": text} if operation == "responses" else {"messages": [{"role": "user", "content": text}]} + response = client.post("/openai/v1/" + operation, json={"stream": streaming, **subject}) + if guarded: + assert response.status_code == (200 if streaming and blocked_stage == "output" else 400) + assert b"content_blocked" in response.content + assert b"BLOCK_OUTPUT" not in response.content + assert len(requests) == (0 if blocked_stage == "input" else 1) + else: + assert response.status_code == 200 + assert len(requests) == 1 + assert response.content == bodies[0].body + if blocked_stage == "output": + assert b"BLOCK_OUTPUT" in response.content + + @pytest.mark.parametrize("streaming", [False, True]) @pytest.mark.parametrize("operation", ["responses", "chat/completions"]) def test_both_operations_work_through_the_existing_application(proxy, streaming, operation): From 0c7b621e545ad0c80ce31024031cef0a0d94bd0e Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:14:57 +0200 Subject: [PATCH 06/11] fix(server): recognize native OpenAI Responses metadata Accept access_programs, billing, frequency_penalty, presence_penalty, store, and tool_usage as opaque metadata in buffered responses and lifecycle snapshots. Regenerate the inspection contract from the typed declarations. Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- .../openai/_generated/responses.guard.yaml | 126 ++++++++++++++++++ .../openai/responses/response_projection.py | 6 + 2 files changed, 132 insertions(+) diff --git a/nemoguardrails/server/experimental/contracts/openai/_generated/responses.guard.yaml b/nemoguardrails/server/experimental/contracts/openai/_generated/responses.guard.yaml index 1054bef018..f1fd00907d 100644 --- a/nemoguardrails/server/experimental/contracts/openai/_generated/responses.guard.yaml +++ b/nemoguardrails/server/experimental/contracts/openai/_generated/responses.guard.yaml @@ -247,9 +247,15 @@ response: additionalProperties: false description: Bind one assistant text target; replacement is deliberately unsupported. properties: + access_programs: + x-nemo-guardrails: + classification: opaque background: x-nemo-guardrails: classification: opaque + billing: + x-nemo-guardrails: + classification: opaque completed_at: x-nemo-guardrails: classification: opaque @@ -262,6 +268,9 @@ response: error: x-nemo-guardrails: classification: opaque + frequency_penalty: + x-nemo-guardrails: + classification: opaque id: x-nemo-guardrails: classification: opaque @@ -447,6 +456,9 @@ response: parallel_tool_calls: x-nemo-guardrails: classification: opaque + presence_penalty: + x-nemo-guardrails: + classification: opaque previous_response_id: x-nemo-guardrails: classification: opaque @@ -477,6 +489,9 @@ response: status: x-nemo-guardrails: classification: opaque + store: + x-nemo-guardrails: + classification: opaque temperature: x-nemo-guardrails: classification: opaque @@ -486,6 +501,9 @@ response: tool_choice: x-nemo-guardrails: classification: opaque + tool_usage: + x-nemo-guardrails: + classification: opaque tools: x-nemo-guardrails: classification: opaque @@ -1178,9 +1196,15 @@ stream: description: Describe a full response snapshot without demanding a message before generation. properties: + access_programs: + x-nemo-guardrails: + classification: opaque background: x-nemo-guardrails: classification: opaque + billing: + x-nemo-guardrails: + classification: opaque completed_at: x-nemo-guardrails: classification: opaque @@ -1193,6 +1217,9 @@ stream: error: x-nemo-guardrails: classification: opaque + frequency_penalty: + x-nemo-guardrails: + classification: opaque id: x-nemo-guardrails: classification: opaque @@ -1380,6 +1407,9 @@ stream: parallel_tool_calls: x-nemo-guardrails: classification: opaque + presence_penalty: + x-nemo-guardrails: + classification: opaque previous_response_id: x-nemo-guardrails: classification: opaque @@ -1410,6 +1440,9 @@ stream: status: x-nemo-guardrails: classification: opaque + store: + x-nemo-guardrails: + classification: opaque temperature: x-nemo-guardrails: classification: opaque @@ -1419,6 +1452,9 @@ stream: tool_choice: x-nemo-guardrails: classification: opaque + tool_usage: + x-nemo-guardrails: + classification: opaque tools: x-nemo-guardrails: classification: opaque @@ -1485,9 +1521,15 @@ stream: description: Describe a full response snapshot without demanding a message before generation. properties: + access_programs: + x-nemo-guardrails: + classification: opaque background: x-nemo-guardrails: classification: opaque + billing: + x-nemo-guardrails: + classification: opaque completed_at: x-nemo-guardrails: classification: opaque @@ -1500,6 +1542,9 @@ stream: error: x-nemo-guardrails: classification: opaque + frequency_penalty: + x-nemo-guardrails: + classification: opaque id: x-nemo-guardrails: classification: opaque @@ -1687,6 +1732,9 @@ stream: parallel_tool_calls: x-nemo-guardrails: classification: opaque + presence_penalty: + x-nemo-guardrails: + classification: opaque previous_response_id: x-nemo-guardrails: classification: opaque @@ -1717,6 +1765,9 @@ stream: status: x-nemo-guardrails: classification: opaque + store: + x-nemo-guardrails: + classification: opaque temperature: x-nemo-guardrails: classification: opaque @@ -1726,6 +1777,9 @@ stream: tool_choice: x-nemo-guardrails: classification: opaque + tool_usage: + x-nemo-guardrails: + classification: opaque tools: x-nemo-guardrails: classification: opaque @@ -1793,9 +1847,15 @@ stream: description: Describe a full response snapshot without demanding a message before generation. properties: + access_programs: + x-nemo-guardrails: + classification: opaque background: x-nemo-guardrails: classification: opaque + billing: + x-nemo-guardrails: + classification: opaque completed_at: x-nemo-guardrails: classification: opaque @@ -1808,6 +1868,9 @@ stream: error: x-nemo-guardrails: classification: opaque + frequency_penalty: + x-nemo-guardrails: + classification: opaque id: x-nemo-guardrails: classification: opaque @@ -1995,6 +2058,9 @@ stream: parallel_tool_calls: x-nemo-guardrails: classification: opaque + presence_penalty: + x-nemo-guardrails: + classification: opaque previous_response_id: x-nemo-guardrails: classification: opaque @@ -2025,6 +2091,9 @@ stream: status: x-nemo-guardrails: classification: opaque + store: + x-nemo-guardrails: + classification: opaque temperature: x-nemo-guardrails: classification: opaque @@ -2034,6 +2103,9 @@ stream: tool_choice: x-nemo-guardrails: classification: opaque + tool_usage: + x-nemo-guardrails: + classification: opaque tools: x-nemo-guardrails: classification: opaque @@ -2100,9 +2172,15 @@ stream: description: Describe a full response snapshot without demanding a message before generation. properties: + access_programs: + x-nemo-guardrails: + classification: opaque background: x-nemo-guardrails: classification: opaque + billing: + x-nemo-guardrails: + classification: opaque completed_at: x-nemo-guardrails: classification: opaque @@ -2115,6 +2193,9 @@ stream: error: x-nemo-guardrails: classification: opaque + frequency_penalty: + x-nemo-guardrails: + classification: opaque id: x-nemo-guardrails: classification: opaque @@ -2302,6 +2383,9 @@ stream: parallel_tool_calls: x-nemo-guardrails: classification: opaque + presence_penalty: + x-nemo-guardrails: + classification: opaque previous_response_id: x-nemo-guardrails: classification: opaque @@ -2332,6 +2416,9 @@ stream: status: x-nemo-guardrails: classification: opaque + store: + x-nemo-guardrails: + classification: opaque temperature: x-nemo-guardrails: classification: opaque @@ -2341,6 +2428,9 @@ stream: tool_choice: x-nemo-guardrails: classification: opaque + tool_usage: + x-nemo-guardrails: + classification: opaque tools: x-nemo-guardrails: classification: opaque @@ -2407,9 +2497,15 @@ stream: description: Describe a full response snapshot without demanding a message before generation. properties: + access_programs: + x-nemo-guardrails: + classification: opaque background: x-nemo-guardrails: classification: opaque + billing: + x-nemo-guardrails: + classification: opaque completed_at: x-nemo-guardrails: classification: opaque @@ -2422,6 +2518,9 @@ stream: error: x-nemo-guardrails: classification: opaque + frequency_penalty: + x-nemo-guardrails: + classification: opaque id: x-nemo-guardrails: classification: opaque @@ -2609,6 +2708,9 @@ stream: parallel_tool_calls: x-nemo-guardrails: classification: opaque + presence_penalty: + x-nemo-guardrails: + classification: opaque previous_response_id: x-nemo-guardrails: classification: opaque @@ -2639,6 +2741,9 @@ stream: status: x-nemo-guardrails: classification: opaque + store: + x-nemo-guardrails: + classification: opaque temperature: x-nemo-guardrails: classification: opaque @@ -2648,6 +2753,9 @@ stream: tool_choice: x-nemo-guardrails: classification: opaque + tool_usage: + x-nemo-guardrails: + classification: opaque tools: x-nemo-guardrails: classification: opaque @@ -2714,9 +2822,15 @@ stream: description: Describe a full response snapshot without demanding a message before generation. properties: + access_programs: + x-nemo-guardrails: + classification: opaque background: x-nemo-guardrails: classification: opaque + billing: + x-nemo-guardrails: + classification: opaque completed_at: x-nemo-guardrails: classification: opaque @@ -2729,6 +2843,9 @@ stream: error: x-nemo-guardrails: classification: opaque + frequency_penalty: + x-nemo-guardrails: + classification: opaque id: x-nemo-guardrails: classification: opaque @@ -2916,6 +3033,9 @@ stream: parallel_tool_calls: x-nemo-guardrails: classification: opaque + presence_penalty: + x-nemo-guardrails: + classification: opaque previous_response_id: x-nemo-guardrails: classification: opaque @@ -2946,6 +3066,9 @@ stream: status: x-nemo-guardrails: classification: opaque + store: + x-nemo-guardrails: + classification: opaque temperature: x-nemo-guardrails: classification: opaque @@ -2955,6 +3078,9 @@ stream: tool_choice: x-nemo-guardrails: classification: opaque + tool_usage: + x-nemo-guardrails: + classification: opaque tools: x-nemo-guardrails: classification: opaque diff --git a/nemoguardrails/server/experimental/providers/openai/responses/response_projection.py b/nemoguardrails/server/experimental/providers/openai/responses/response_projection.py index 293ad0d476..ac0569d0ae 100644 --- a/nemoguardrails/server/experimental/providers/openai/responses/response_projection.py +++ b/nemoguardrails/server/experimental/providers/openai/responses/response_projection.py @@ -69,11 +69,14 @@ class ResponsesAssistantMessageProjection(PolicyModel, GuardedContentModel): RESPONSE_METADATA_FIELDS = ( + "access_programs", "background", + "billing", "completed_at", "conversation", "created_at", "error", + "frequency_penalty", "id", "incomplete_details", "instructions", @@ -84,6 +87,7 @@ class ResponsesAssistantMessageProjection(PolicyModel, GuardedContentModel): "moderation", "object", "parallel_tool_calls", + "presence_penalty", "previous_response_id", "prompt", "prompt_cache_diagnostics", @@ -94,9 +98,11 @@ class ResponsesAssistantMessageProjection(PolicyModel, GuardedContentModel): "safety_identifier", "service_tier", "status", + "store", "temperature", "text", "tool_choice", + "tool_usage", "tools", "top_logprobs", "top_p", From 753e24879987f11d1c1cea542a699e50f51549a7 Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:15:11 +0200 Subject: [PATCH 07/11] test(server): add native OpenAI Responses recorded coverage Add authentic buffered and streaming success and provider-error recordings, offline policy and byte-preservation replay, and output-blocking regressions. Document capture provenance and reuse a shared proxy test harness. Conformance inventories and classification mutations remain in the downstream conformance branch. Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- .../providers/openai/responses/README.md | 25 +- tests/recorded/server/experimental/README.md | 10 + ...provider_error_is_preserved[buffered].yaml | 54 ++++ ...rovider_error_is_preserved[streaming].yaml | 54 ++++ ...es_preserve_policy_and_wire[buffered].yaml | 129 ++++++++++ ...s_preserve_policy_and_wire[streaming].yaml | 97 ++++++++ .../experimental/openai/test_responses.py | 233 ++++++++++++++++++ tests/server/experimental/responses_proxy.py | 66 +++++ 8 files changed, 666 insertions(+), 2 deletions(-) create mode 100644 tests/recorded/server/experimental/openai/cassettes/test_responses/test_native_provider_error_is_preserved[buffered].yaml create mode 100644 tests/recorded/server/experimental/openai/cassettes/test_responses/test_native_provider_error_is_preserved[streaming].yaml create mode 100644 tests/recorded/server/experimental/openai/cassettes/test_responses/test_native_responses_preserve_policy_and_wire[buffered].yaml create mode 100644 tests/recorded/server/experimental/openai/cassettes/test_responses/test_native_responses_preserve_policy_and_wire[streaming].yaml create mode 100644 tests/recorded/server/experimental/openai/test_responses.py create mode 100644 tests/server/experimental/responses_proxy.py diff --git a/nemoguardrails/server/experimental/providers/openai/responses/README.md b/nemoguardrails/server/experimental/providers/openai/responses/README.md index 78db78f352..b53a31352d 100644 --- a/nemoguardrails/server/experimental/providers/openai/responses/README.md +++ b/nemoguardrails/server/experimental/providers/openai/responses/README.md @@ -141,8 +141,29 @@ baseline. The old Responses implementation provided characterization cases, not authoritative generated models. The [official streaming guide](https://developers.openai.com/api/docs/guides/streaming-responses) describes semantic SSE events; this integration intentionally supports only the -reviewed subset above. Tests use synthetic API-shaped payloads, not recordings -or a claim of compatibility with every OpenAI-compatible server. +reviewed subset above. Synthetic tests cover unsupported shapes; the native +[recorded suite](../../../../../../tests/recorded/server/experimental/openai/test_responses.py) +also exercises successful buffered and streaming exchanges and genuine HTTP +errors from `https://api.openai.com/v1/responses`. This is evidence for the +captured native profile, not every OpenAI-compatible server. + +The captures use `gpt-5-nano-2025-08-07` with minimal reasoning, a short benign +greeting, and `store: false`, matching the model family used by the LiteLLM +comparison. They were captured on 2026-10-21. The model is a historical +compatibility baseline, not a recommendation for new applications. Refresh +requires `OPENAI_API_KEY`; default replay supplies a dummy key and blocks the +network. No SDK or additional provider extra is needed beyond the repository's +recording dependencies and the experimental HTTP proxy dependencies. + +Both native modes return `store`, `access_programs`, `frequency_penalty`, +`presence_penalty`, and `tool_usage`; buffered output also includes `billing`. +These are response metadata, not LiteLLM-only extensions. The base contract +preserves them uninspected in buffered responses and lifecycle snapshots. This +does not admit them as new request controls or permit unreviewed output items. + +The pinned schema's `Response` properties omit `store` even though its own +text-response example contains it. Native recordings provide additional evidence +for accepting this metadata. Focused tests cover projections, export parity, protocol behavior, fragmented SSE, blocked output, and application composition with real offline regex Rails diff --git a/tests/recorded/server/experimental/README.md b/tests/recorded/server/experimental/README.md index b8913535eb..500bfda754 100644 --- a/tests/recorded/server/experimental/README.md +++ b/tests/recorded/server/experimental/README.md @@ -32,6 +32,16 @@ comparison explicitly checks consumption, rewinds the cassette once, and then checks the uninspected pass separately. Unrestricted playback repeats are not enabled, so extra dispatches or HTTP retries cannot reuse the recorded response. +## Native Responses coverage + +The [Responses suite](openai/test_responses.py) calls native OpenAI during +authorized capture and selects the base Responses endpoint during offline replay. +Its four cassettes cover buffered/streaming success and genuine invalid-model +errors requested in both modes. Successful recordings are reused with synthetic +checker verdicts for buffered blocking, first/later streaming-window blocking, +and overlapping context. +Responses errors use native SSE framing without a Chat `[DONE]` marker. + Blocking tests reference the existing successful cassettes and force `record_mode="none"`. They cannot refresh or manufacture evidence. Refresh the original success tests when authorized; do not create separate provider diff --git a/tests/recorded/server/experimental/openai/cassettes/test_responses/test_native_provider_error_is_preserved[buffered].yaml b/tests/recorded/server/experimental/openai/cassettes/test_responses/test_native_provider_error_is_preserved[buffered].yaml new file mode 100644 index 0000000000..f26d0c8210 --- /dev/null +++ b/tests/recorded/server/experimental/openai/cassettes/test_responses/test_native_provider_error_is_preserved[buffered].yaml @@ -0,0 +1,54 @@ +version: 1 +interactions: +- request: + method: POST + uri: https://api.openai.com/v1/responses + headers: + host: + - api.openai.com + accept: + - '*/*' + connection: + - keep-alive + user-agent: + - python-httpx/0.28.1 + accept-encoding: + - identity + content-type: + - application/json + parsed_body: + model: recorded-invalid-responses-model + input: 'Reply with exactly: Hello, recorded Responses!' + stream: false + store: false + max_output_tokens: 256 + reasoning: + effort: minimal + metadata: + test: native-responses + response: + status: + code: 404 + message: Not Found + headers: + content-type: + - application/json + connection: + - keep-alive + server: + - cloudflare + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + access-control-expose-headers: + - X-Request-ID + - CF-Ray + - CF-Ray + alt-svc: + - h3=":443"; ma=86400 + body: + parsed_body: + error: + message: The model `recorded-invalid-responses-model` does not exist or you do not have access to it. + type: invalid_request_error + param: null + code: model_not_found diff --git a/tests/recorded/server/experimental/openai/cassettes/test_responses/test_native_provider_error_is_preserved[streaming].yaml b/tests/recorded/server/experimental/openai/cassettes/test_responses/test_native_provider_error_is_preserved[streaming].yaml new file mode 100644 index 0000000000..4426e4ea7f --- /dev/null +++ b/tests/recorded/server/experimental/openai/cassettes/test_responses/test_native_provider_error_is_preserved[streaming].yaml @@ -0,0 +1,54 @@ +version: 1 +interactions: +- request: + method: POST + uri: https://api.openai.com/v1/responses + headers: + host: + - api.openai.com + accept: + - '*/*' + connection: + - keep-alive + user-agent: + - python-httpx/0.28.1 + accept-encoding: + - identity + content-type: + - application/json + parsed_body: + model: recorded-invalid-responses-model + input: 'Reply with exactly: Hello, recorded Responses!' + stream: true + store: false + max_output_tokens: 256 + reasoning: + effort: minimal + metadata: + test: native-responses + response: + status: + code: 404 + message: Not Found + headers: + content-type: + - application/json + connection: + - keep-alive + server: + - cloudflare + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + access-control-expose-headers: + - X-Request-ID + - CF-Ray + - CF-Ray + alt-svc: + - h3=":443"; ma=86400 + body: + parsed_body: + error: + message: The model `recorded-invalid-responses-model` does not exist or you do not have access to it. + type: invalid_request_error + param: null + code: model_not_found diff --git a/tests/recorded/server/experimental/openai/cassettes/test_responses/test_native_responses_preserve_policy_and_wire[buffered].yaml b/tests/recorded/server/experimental/openai/cassettes/test_responses/test_native_responses_preserve_policy_and_wire[buffered].yaml new file mode 100644 index 0000000000..655e1198dd --- /dev/null +++ b/tests/recorded/server/experimental/openai/cassettes/test_responses/test_native_responses_preserve_policy_and_wire[buffered].yaml @@ -0,0 +1,129 @@ +version: 1 +interactions: +- request: + method: POST + uri: https://api.openai.com/v1/responses + headers: + host: + - api.openai.com + accept: + - '*/*' + connection: + - keep-alive + user-agent: + - python-httpx/0.28.1 + accept-encoding: + - identity + content-type: + - application/json + parsed_body: + model: gpt-5-nano-2025-08-07 + input: 'Reply with exactly: Hello, recorded Responses!' + stream: false + store: false + max_output_tokens: 256 + reasoning: + effort: minimal + metadata: + test: native-responses + response: + status: + code: 200 + message: OK + headers: + content-type: + - application/json + connection: + - keep-alive + server: + - cloudflare + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + access-control-expose-headers: + - X-Request-ID + - CF-Ray + - CF-Ray + alt-svc: + - h3=":443"; ma=86400 + body: + parsed_body: + id: '[RECORDED_RESPONSE_ID]' + object: response + created_at: 1792599265 + status: completed + access_programs: null + background: false + billing: + payer: developer + completed_at: 1792599266 + error: null + frequency_penalty: 0.0 + incomplete_details: null + instructions: null + max_output_tokens: 256 + max_tool_calls: null + model: gpt-5-nano-2025-08-07 + moderation: null + output: + - id: rs_05613daf1bd13490016ad8e4e211e087d2a9152a28bbe7a0e5 + type: reasoning + content: [] + encrypted_content: gAAAAABq2OTiQSZ5PEfULmoBaJGfc25PzCXhS_NhMsRUWHWuwHGt9ONSlg_fLBMxSuQMtHtI5EL7ACdQtAJaziKvO44q75m12f-H-R2kZ8FLA19DWK1dxwOM_BdU7Yk4iJse8rcQFdXS9EzCJMMqbuyXX_YTbG_QBVFZb_hRW4RKe3FPeF3rGbv0AGHwHkBGu9vtySfRSV4Fihk_hOQceLCj8evwgHOTjCsgmFhDl5a3ESv15N5FbBSrkQfM9zGGhBhJvLcGedv6wA-R-KPuKootLo5C52e4XC0yevREW0460BDQ74X4rAzIQGtwHwE-QxLWAyLktwAcJW4HEhoReCRb5hRqaADn5UCK5Y6i0f3X0HTSLCG0JbeZLH3bj4tRCY-nEgqtXgc_vBNFtIOrNoAls92_1jriwkf4gofVJpejqbMAIVl3hCE1HsHrMdQI9feZKUKY6iC8EVJwG3JltAhd4tLwAUYDIlz329R8FcHbVkNCVXZmJmVr-odsE3brsPjBeQBhOeO9aRehnz2sQ2ogoBfdFGuGrJ4AhKqOJGnl3zo_rgt8CBnb0XXAFMnL5QAeLh2Znd4yzGU8nbeNFGjlV7dCuETuqgjjMmbJ75fWsVR5FsrUpMmxmSIVJIDh4VmXuwxTPie71MCsEgbMnWoU6m3afOO9RCkbQPJCWk1lBslRXjDfs-WG_3r7KwbDAPVUHQDFpHdGgAnkwqnDPFtZ0mMjmpfXIv-0k_QxtnhRPFE1uAKimxyXzd6V_p01Ob_EIHg0dgYIcuIBCR2LEy4MX_Is8-9HreTlJek86McJy9Q5_5erBOOvJX55GRAyEOvs4DQNVB0-QZ8wV-CcVc34JZnb0T2T2eiI3BY2Trili9ap104WzDNxpSil11mPHo1nUrUDvxn3ONz-U7zy6bigYVqJyC3XIdlKqcTZBuy2qwRCJB9-cvxDi8pDaaRd97fIGN2y88zs2QSjThmXDANQARFnglzP1-COUABYzCVkaer9nTrK2k8llIx5ruXXILJgyNRJPAsAAS-Kgpq8ZrS0bdKSZUp4X0iCtwvcSUKZylOy84mG-LGxgIl9vMgsw3F-sq2wgr1BeASFVswEgG4pdYnWLLWUIJ0_1TYBqa3Zq1B2rLIfdgX8p5BWtd0dVDNka2ewHnA3B5VQ_lmQKPCUO9fDvs0TmXTEeLAheIEQvD3OB3waix1DOJTjX4GB7AylCUIxo7YxxCPOvL9Rs0oX_M4CRsM1Kd9kEi7Er57gFbOjTj5A602jbZio2QdQfdiSPbIadN5Cj4kF9M4UoeoqDQ5L1KGujuppigLR95LmAujvKPpfaXxv8oOofPz-rdmS_SBR3Deoa7Vbr8esBSY72ZDLTgbplcmxjYVA4ozNIfrOhLc1jsLh5PamSZUf8SA4lGhPXCfKll-uQVtmmV2hf9P6SO2Nvbe32ib6HrjN2A6rd4yUPAgALbqpafdNZBo68dLbsNB5ev9vBaro-dEj3N8dDjEqz6QLm9L6j_HWElMxRNdRYn7dYHfD5d3IjGPdcEZWLxQVOd7y73ZXjX4WZDRTpKdCDDKnv_p5D6opk6fg39gkzw_4-v95zeGZMYkiY_saDOtIoUAFOEjpTPca7LTYtc1vcZWSwiBGJ9z7GUmbz74PXdg= + summary: [] + - id: msg_05613daf1bd13490016ad8e4e223f087d298f06028936405ad + type: message + status: completed + content: + - type: output_text + annotations: [] + logprobs: [] + text: Hello, recorded Responses! + role: assistant + parallel_tool_calls: true + presence_penalty: 0.0 + previous_response_id: null + prompt_cache_key: null + prompt_cache_retention: in_memory + reasoning: + context: current_turn + effort: minimal + mode: standard + summary: null + safety_identifier: null + service_tier: default + store: false + temperature: 1.0 + text: + format: + type: text + verbosity: medium + tool_choice: auto + tool_usage: + image_gen: + input_tokens: 0 + input_tokens_details: + image_tokens: 0 + text_tokens: 0 + output_tokens: 0 + output_tokens_details: + image_tokens: 0 + text_tokens: 0 + total_tokens: 0 + web_search: + num_requests: 0 + tools: [] + top_logprobs: 0 + top_p: 1.0 + truncation: disabled + usage: + input_tokens: 15 + input_tokens_details: + cache_write_tokens: 0 + cached_tokens: 0 + output_tokens: 23 + output_tokens_details: + reasoning_tokens: 0 + total_tokens: 38 + user: null + metadata: + test: native-responses diff --git a/tests/recorded/server/experimental/openai/cassettes/test_responses/test_native_responses_preserve_policy_and_wire[streaming].yaml b/tests/recorded/server/experimental/openai/cassettes/test_responses/test_native_responses_preserve_policy_and_wire[streaming].yaml new file mode 100644 index 0000000000..1ee117206d --- /dev/null +++ b/tests/recorded/server/experimental/openai/cassettes/test_responses/test_native_responses_preserve_policy_and_wire[streaming].yaml @@ -0,0 +1,97 @@ +version: 1 +interactions: +- request: + method: POST + uri: https://api.openai.com/v1/responses + headers: + host: + - api.openai.com + accept: + - '*/*' + connection: + - keep-alive + user-agent: + - python-httpx/0.28.1 + accept-encoding: + - identity + content-type: + - application/json + parsed_body: + model: gpt-5-nano-2025-08-07 + input: 'Reply with exactly: Hello, recorded Responses!' + stream: true + store: false + max_output_tokens: 256 + reasoning: + effort: minimal + metadata: + test: native-responses + response: + status: + code: 200 + message: OK + headers: + content-type: + - text/event-stream; charset=utf-8 + transfer-encoding: + - chunked + connection: + - keep-alive + server: + - cloudflare + strict-transport-security: + - max-age=31536000; includeSubDomains; preload + access-control-expose-headers: + - X-Request-ID + - CF-Ray + - CF-Ray + alt-svc: + - h3=":443"; ma=86400 + body: + string: |+ + event: response.created + data: {"type":"response.created","response":{"id":"resp_0017227e5b5c47f4016ad8e4fce9e487d29b95ab4428a166c9","object":"response","created_at":1792599292,"status":"in_progress","access_programs":null,"background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":256,"max_tool_calls":null,"model":"gpt-5-nano-2025-08-07","moderation":null,"output":[],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":"in_memory","reasoning":{"context":"current_turn","effort":"minimal","mode":"standard","summary":null},"safety_identifier":null,"service_tier":"auto","store":false,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tool_usage":{"image_gen":{"input_tokens":0,"input_tokens_details":{"image_tokens":0,"text_tokens":0},"output_tokens":0,"output_tokens_details":{"image_tokens":0,"text_tokens":0},"total_tokens":0},"web_search":{"num_requests":0}},"tools":[],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{"test":"native-responses"}},"sequence_number":0} + + event: response.in_progress + data: {"type":"response.in_progress","response":{"id":"resp_0017227e5b5c47f4016ad8e4fce9e487d29b95ab4428a166c9","object":"response","created_at":1792599292,"status":"in_progress","access_programs":null,"background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":256,"max_tool_calls":null,"model":"gpt-5-nano-2025-08-07","moderation":null,"output":[],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":"in_memory","reasoning":{"context":"current_turn","effort":"minimal","mode":"standard","summary":null},"safety_identifier":null,"service_tier":"auto","store":false,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tool_usage":{"image_gen":{"input_tokens":0,"input_tokens_details":{"image_tokens":0,"text_tokens":0},"output_tokens":0,"output_tokens_details":{"image_tokens":0,"text_tokens":0},"total_tokens":0},"web_search":{"num_requests":0}},"tools":[],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{"test":"native-responses"}},"sequence_number":1} + + event: response.output_item.added + data: {"type":"response.output_item.added","item":{"id":"rs_0017227e5b5c47f4016ad8e4fdaadc87d29eb34b02de221f57","type":"reasoning","content":[],"encrypted_content":"gAAAAABq2OT9nF1ikH4hczXb0EnrPep5gTnKPQSyts9tJ2s-j-K0M8_WLZYz5PXbonTzT8COeUhBZqxTB-7GZ4zZq5FGKC2ehi3PWqF_QisaRarXpEbdVplAA2_zqduT5w6wpIbabJyRvmjq9D2o2Xu7Uu_VVieRETT-mZFOaSylNQiIqhjNzBhGJpMcixyuFgXiqlUbCjcRBKfErfSotMEgRX0TDSlzr7gKpPTpkabvSd60NojnuTBMCgUF0eb1XrtVmogp40SG2YEPbTDn7TwhLPH60wSU2mKVrNBRRt9rHGgGzK3z6W-NZihXl-bO6kuHIsHBy8kaEGZ_ZRiGakbQQ4-DgX0f4irbtFYJ9zMew25exRO5mnE7u82sMVlBfr6qNfw_ho9SUjThyIK3ptCDfmkp4md7lVgunj9FwzUsMfKNVpCI3gZqavv-11Fl3UA56rsWunUwfiHsfRj8FbuYV8zGJRgCYX5rrQ3GDzcFRvZuxXcG0fvAZ0lYVXfmeWYZZq_5brDdhessioE9M9XcXcNGQt5-EFnfps5FLeZiz6W_0pav66IjUFWR0M5gOibkeXAHKBsHYsgBTCPPZ1mp5qo3Ol8bqGMTf0_a8xavwrzUbwxGOVsps0K-PNTJ8SxzwWG85yebE3xoRVK9tpL9JTIUWJmfa9zv47CzMNO8RJ2ufvEiNVsElEu7Myr6_s6twPukK0l696nHj5mInMql2wguBBOm4erRyG3Tkb2ZIyDXv3PN0gPMxqEBu2DWz_H-ZQn4-Wza1je8o1z5JOMkjTW0YQoSFeznpkPhLS7PH2w2eNugrABJPAm4iVTeL59uwOVtG6N7n7WYppf99Fp9Cfd6OjO-kCzbi32xLCFtlsogj2It0wLFtoOxjce_4iwigNlEQUcWYct29yM4I8U7VNUTEGMXsV9i8wo7Kj9ntA0P9OaQlDZSMwRrMboFKaNAswkQmSxzvqbchYzQHdasBhFIOOXy2RtjGgXa2y02Jp5fhMMcVN-Pj9K__E4ldWoYFqBdnOCk1MqZYAAxY-7a2JMYHPWkQIOxfVFvV82O6jmZ3EFUr6mnORh8bX5ylEKsUa5gBWw2rd92xM7wGJKvznqO_02t6Z-RWv6CIXpI6Cm1AGhldsuq_5jmg1rVlghwgEykWK1cqu2-FJ2iBjpUQoMs4p7KYCTm3X8JELAF013jJmL7gwt_kjGyDPd7kcGgsgrOAovp","summary":[]},"output_index":0,"sequence_number":2} + + event: response.output_item.done + data: {"type":"response.output_item.done","item":{"id":"rs_0017227e5b5c47f4016ad8e4fdaadc87d29eb34b02de221f57","type":"reasoning","content":[],"encrypted_content":"gAAAAABq2OT93E2rTSLBvBP_z_ChDgeszis4YarG8iXVK4UzlXrVHoHU8tWKJ5dc3TlEHfhdWyxIvvoofKKrf9KCWM4-3_r7ccWgv7apAGfyvboMwgZVxVbYFnwpbBLt-Bki9kTZaly3dRrqDoLhWzfSvfEpgC92VnFWfIi0ZIqLU63bU0_vaQlc7bSZgTdn7s67bb4WOiYzQymEogs5J_ux84qeBVRqoQXI-JK6FxXKB-QsMolTPpLJb0EjxEAa9UvPvFUr_nHKo-Wenw5PaCrWa2idl2X8MK8hVQ7WrsSvEQ1GTmJ2_qk9esy-9WM-wsKU8AQyi9bzVm99OPb_gZyYhhazlrCL-FDei4ewxdhBHf4wQUv3XOhBIqCvWGW-uMH8k3CnZ8ESVkaypUjpGhe-Cxij8nJq-RMkqpcG0PDWGTYSxzKVufnsX58E2HwySqgDjaKtURJw94TQoCYZWM6-t5hBkkvulydG0uMuupc7gcj2JAdNq9JhAoYXRH8vOsaf1jRpnNzApooyfZ6RLHwd07dvHyBCpj3JgtXXj7HmARUvAWkvc2LgAnliBJ_9rR7qJYlV0zPqGLVrtjPekOkZy3pZXfX3taq5JHoiUihZuy_Bt81ev34VQUf0QikJObVSKl167VdygLbpRXj9RSjTQsJ6KF6wZCxs_ZGmLUy-yQ18zjPM8x58OaM1Ip4TfF4q4ex80cYLVglWkQh6sc1aTz4ryyByahRpBf_XfwLA9HR6Eyt9VHFmhhg1QRT-ZcTVRkGlcciuGr3vk3ZwB7dxJEtr5aX2X-wKfSZv06uvjceV3pzpzHk9OxtRFMzZrxJRIL3_ZsTpHwLACQ1NtYwcatcYiE-FfW9wVbkLF7hCMiUJwDfTvTu2l8UfA6EiiCQc_v6rWt1IAaac623JWuF7hUU0pz8_4J-MDr5MtseQ7iXKHar4e3nWMvxau9MCQU___oY8cGagnQxOAdZDCpVeBtYz5rtRO40smKQMXRa3YTLqBF4KzHDjZlg0lFaUl0J-59l0m25XFjWraFVwPpRvwo32Vd3Xtn3KLY90v2PjMuT7s3UWhda2JCfPW3I9BJqQkXppuKv3y3gEIuEsSHEo5diH8wGzTCa732k862wJMtoQRThbPFro8bWQFlEhKx3kALF_jcILIptyxtWZHjOV6lJL-boaJQmJEUVMVPH-lwW-RgDIuQqlRHRsKp5Lm8_0nZ1Y981WXOZ62tlvWDz3zN6x2J4vhiDrdtxYZVH0YIaM1TYYE3TgOf8A1loej56wP0enzYyBF2fLK7qz6-Pncn2GtjvsbwYN9zT-yw7zhQ4ZmCbt30C7VACa9e2zSsdsIpATDZi6XG3Y1O_P88OFf63o-_80GQNGM1ApqVmuH8Bdp4DVaDzJ0VerDozTPyZJJpM2lhzK1TE9uqv9LnuN0ztatGMC4nyUUFM1KhM9EyLH0EBoCFw6Nh4cJ-lavTwHBp1NB0QHU36Bep74l-OnFCwQFAy6xkixoh01ndquvAkpfIEtWxwmu5Qoxg6-Gc3D71yryBndZ8KeUqnd5-T0IwwnvnD04_B-Yc4vKnWaC1UTw9B4YBjyUxucYVMtDds3G3O0AojJOvQwcMLBRWlqMm1sbAERliSa4Ijw0e7NxGuABUhc0p4=","summary":[]},"output_index":0,"sequence_number":3} + + event: response.output_item.added + data: {"type":"response.output_item.added","item":{"id":"msg_0017227e5b5c47f4016ad8e4fdc15487d2951b4ffc0472d783","type":"message","status":"in_progress","content":[],"role":"assistant"},"output_index":1,"sequence_number":4} + + event: response.content_part.added + data: {"type":"response.content_part.added","content_index":0,"item_id":"msg_0017227e5b5c47f4016ad8e4fdc15487d2951b4ffc0472d783","output_index":1,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""},"sequence_number":5} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","content_index":0,"delta":"Hello","item_id":"msg_0017227e5b5c47f4016ad8e4fdc15487d2951b4ffc0472d783","logprobs":[],"obfuscation":"xKo2g8o64Pt","output_index":1,"sequence_number":6} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","content_index":0,"delta":",","item_id":"msg_0017227e5b5c47f4016ad8e4fdc15487d2951b4ffc0472d783","logprobs":[],"obfuscation":"E4JyNuXSGXz0AuM","output_index":1,"sequence_number":7} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","content_index":0,"delta":" recorded","item_id":"msg_0017227e5b5c47f4016ad8e4fdc15487d2951b4ffc0472d783","logprobs":[],"obfuscation":"oBHqwlu","output_index":1,"sequence_number":8} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","content_index":0,"delta":" Responses","item_id":"msg_0017227e5b5c47f4016ad8e4fdc15487d2951b4ffc0472d783","logprobs":[],"obfuscation":"f28LGE","output_index":1,"sequence_number":9} + + event: response.output_text.delta + data: {"type":"response.output_text.delta","content_index":0,"delta":"!","item_id":"msg_0017227e5b5c47f4016ad8e4fdc15487d2951b4ffc0472d783","logprobs":[],"obfuscation":"RsLp5qhcwf3eiP4","output_index":1,"sequence_number":10} + + event: response.output_text.done + data: {"type":"response.output_text.done","content_index":0,"item_id":"msg_0017227e5b5c47f4016ad8e4fdc15487d2951b4ffc0472d783","logprobs":[],"output_index":1,"sequence_number":11,"text":"Hello, recorded Responses!"} + + event: response.content_part.done + data: {"type":"response.content_part.done","content_index":0,"item_id":"msg_0017227e5b5c47f4016ad8e4fdc15487d2951b4ffc0472d783","output_index":1,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":"Hello, recorded Responses!"},"sequence_number":12} + + event: response.output_item.done + data: {"type":"response.output_item.done","item":{"id":"msg_0017227e5b5c47f4016ad8e4fdc15487d2951b4ffc0472d783","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"Hello, recorded Responses!"}],"role":"assistant"},"output_index":1,"sequence_number":13} + + event: response.completed + data: {"type":"response.completed","response":{"id":"resp_0017227e5b5c47f4016ad8e4fce9e487d29b95ab4428a166c9","object":"response","created_at":1792599292,"status":"completed","access_programs":null,"background":false,"completed_at":1792599293,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":256,"max_tool_calls":null,"model":"gpt-5-nano-2025-08-07","moderation":null,"output":[{"id":"rs_0017227e5b5c47f4016ad8e4fdaadc87d29eb34b02de221f57","type":"reasoning","content":[],"encrypted_content":"gAAAAABq2OT94PMn4kZmYxZwS-ipKaOEKpa2ssivLOQAGK-R_P1WGBwkRFNu778ghiqfEa0LFfwPA3vblQPBIdChQRUiWau8qVEBEfq5FNobaJ5biIhrWM4bzimK4sZP1qI7P33EyI0sCqjLu-R5qJ6r54Fit9iWjBOB5HujnKvcVSy5lmTvQJsoXmXRQi0yZPEUjc5_xNKl6WAi5Q4-5BQre8ALWiyqM3CHPRU6P_dvedf3f_ruUHTN3o1Eg45RT12aHYJPy5tMXT0jGRhd-c6HVmXqt88vNTzy0Za8HO3L3-E-zAiN6Nx22K6hFfgfyUx-tMHuuHX5AL-MxKBuzCT7m6NGXzxUMKmO8-Dhk-0mNZF9eprb7fb9wg9KRHmx49MFJu8PAIZmd6zwhAuZApP3uGTn8LcuKhPM6PH95vncFhk7si7CAPyBxqIWxlj-NjCfl8HYshLxulequWCPRvO54rJ59D6CmL3OZUi95fWtH6c8akGdkehesMYFfpXoIdYfr9x-RbXsaCrK3ASPjTV62lgRrp8rNcWUceMzbHaJeH18f4cAQvBGLJToq3PwVag1-znO6oYkea0hf9yKQHREMEpq3M-Jo6Z-SRcePyEbl-PSavgfAOss5ZLlj79Lbp5-BXJDaYhL2YL6sXjrB8GxkDVTAVjZcPSVZ2drw2XwKP6qzwEFc8VQTPbQmwQ0WcTxs5PWFab8DHPry8iuyEGYGarrhdCASOQYln3tP8bTKmb-gJTGHWkqEgGfTD-7p2EFqPCMqiBxdH26qdxigI9afJnTKH_Su7hb-pn2H1cXVywk1zuGgD931sMdeLHhrHeWQ8MccHvJLX61OeN8izW3_jmmgPad6J7lgOZaJxaDnQFUvxAxbk2gMh-G3USGQLopT3wL-glIhjMx9-c3eetOXCB0pbXdThEoOaKnYWzsk4ZW-Jo603gSGzAJoyu_YuVSqxRQfcarmnuphgTNBlteP4RaRMSIS1d2xWDqzf3rT39vGaMStedxLsaqp8hNMz4jh5UEGPsF1bMsQtlZLbQe0T88-zJcd1JzRIHXv_9zhDtm6pR5EPxUNflpggRZr4J41IWVZMf67seIOY0mHNEq6yhMtNlEsupPE7JCX3ZMdxuTOeeeGKRT63if_DuyjQEIWowxkPp-bHCw4JskRR53vckXsfAk3yaf-4xASrjYSFpS9RQsZPXxzCFqDU9JNXlYV9QHGx4S7esiwTda6edUkS03mq9UMeIuTPkaSyBHGWJcjWlQ7qCrVVE6PkYY-qNiT4rJqSK7Y7ctjtFA0RUTNYRzLkXisS5dJcvmm1l6xtHJEP_4WFZg6UO0ufNqnunirLZFJe2oCnA4VYwnUdB2_Ad7qExmAkgfsXw1s4V2UfIJyElcf-U6ikM8hBcmQQTXj77LH1exteEDiAPVUcOYAG6bNTRXg883Jb98mWJPl6mU41m3Sd5xhCrT3cuYPbEpRQ_H3_I8yZ-7Quytmi8ks5ZKotSyAjNPhehh58vURM9YumM0wc1fROVquTZxFGcA5p8u1WrtQifZnoKkDcb4uWx6k0Cs7VcWRkMQ71JaeD-gjLfQiSNbYsSWXoWUPP2TBhJWdD49zMKzG9uBw8GvdL9dfWQ_v2xS24Xsb1rehCHH4GuxJGs=","summary":[]},{"id":"msg_0017227e5b5c47f4016ad8e4fdc15487d2951b4ffc0472d783","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"Hello, recorded Responses!"}],"role":"assistant"}],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":"in_memory","reasoning":{"context":"current_turn","effort":"minimal","mode":"standard","summary":null},"safety_identifier":null,"service_tier":"default","store":false,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tool_usage":{"image_gen":{"input_tokens":0,"input_tokens_details":{"image_tokens":0,"text_tokens":0},"output_tokens":0,"output_tokens_details":{"image_tokens":0,"text_tokens":0},"total_tokens":0},"web_search":{"num_requests":0}},"tools":[],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":15,"input_tokens_details":{"cache_write_tokens":0,"cached_tokens":0},"output_tokens":23,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":38},"user":null,"metadata":{"test":"native-responses"}},"sequence_number":14} + +... diff --git a/tests/recorded/server/experimental/openai/test_responses.py b/tests/recorded/server/experimental/openai/test_responses.py new file mode 100644 index 0000000000..da73401dc7 --- /dev/null +++ b/tests/recorded/server/experimental/openai/test_responses.py @@ -0,0 +1,233 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json + +import httpx +import pytest +from jsonschema import Draft202012Validator + +from nemoguardrails.server.experimental._content_checker import ( + ContentAllowed, + ContentBlocked, + ContentInspectionPolicy, + StreamBufferingPolicy, +) +from nemoguardrails.server.experimental.provider.contract_export import export_guard_contract +from nemoguardrails.server.experimental.provider.sse import iter_sse_events +from nemoguardrails.server.experimental.providers.openai.responses.endpoint import RESPONSES_ENDPOINT +from tests.recorded.cassette import cassette_request_jsons +from tests.server.experimental.checkers import StaticChecker +from tests.server.experimental.responses_proxy import responses_proxy as proxy + +pytestmark = [ + pytest.mark.recorded, + pytest.mark.vcr(ignore_hosts=["recorded-responses.test"]), + pytest.mark.asyncio, +] + +BASE_URL = "https://api.openai.com/v1" +MODEL = "gpt-5-nano-2025-08-07" +INPUT = "Reply with exactly: Hello, recorded Responses!" +ENDPOINT = RESPONSES_ENDPOINT +BUFFERED_RECORDING = "test_native_responses_preserve_policy_and_wire[buffered].yaml" +STREAMING_RECORDING = "test_native_responses_preserve_policy_and_wire[streaming].yaml" + + +def responses_request(stream, *, model=MODEL): + return { + "model": model, + "input": INPUT, + "stream": stream, + "store": False, + "max_output_tokens": 256, + "reasoning": {"effort": "minimal"}, + "metadata": {"test": "native-responses"}, + } + + +async def capture(api_key, request): + async with httpx.AsyncClient(timeout=60) as client: + return await client.post( + BASE_URL + "/responses", + json=request, + headers={"authorization": f"Bearer {api_key}", "accept-encoding": "identity"}, + ) + + +async def stream_events(wire): + async def chunks(): + yield wire + + return [event async for event in iter_sse_events(chunks(), max_event_bytes=1024 * 1024)] + + +def assistant_text(payload): + return "".join( + part["text"] + for item in payload["output"] + if item["type"] == "message" and item["role"] == "assistant" + for part in item["content"] + if part["type"] == "output_text" + ) + + +def assert_consumed_once(cassette): + assert len(cassette.requests) == 1 + assert cassette.play_count == 1 + assert cassette.all_played + + +@pytest.mark.parametrize("stream", [False, True], ids=["buffered", "streaming"]) +async def test_native_responses_preserve_policy_and_wire( + openai_api_key, record_mode, recorded_cassette_path, vcr, stream +): + request = responses_request(stream) + upstream = await capture(openai_api_key, request) + assert upstream.status_code == 200 + if record_mode != "none": + assert len(vcr.requests) == 1 + return + + assert_consumed_once(vcr) + assert cassette_request_jsons(recorded_cassette_path) == [request] + document = export_guard_contract(ENDPOINT) + ENDPOINT.guarded_request_model.validate_payload(request) + Draft202012Validator(document["request"]).validate(request) + if stream: + events = await stream_events(upstream.content) + payloads = [json.loads(event.data) for event in events] + assert payloads[0]["type"] == "response.created" + assert payloads[-1]["type"] == "response.completed" + deltas = [payload["delta"] for payload in payloads if payload["type"] == "response.output_text.delta"] + text = "".join(deltas) + assert assistant_text(payloads[-1]["response"]) == text + assert ENDPOINT.stream is not None + adapter = ENDPOINT.stream.create_adapter() + for event, payload in zip(events, payloads): + Draft202012Validator(document["stream"]).validate(payload) + adapter.classify_event(event) + adapter.validate_end_of_stream() + else: + payload = upstream.json() + text = assistant_text(payload) + parsed = ENDPOINT.guarded_response_model.validate_payload(payload) + assert parsed.locate_guarded_message(payload).message.content == text + Draft202012Validator(document["response"]).validate(payload) + assert text + + for inspected in (True, False): + vcr.rewind() + checker = StaticChecker( + ContentInspectionPolicy(inspected, inspected, StreamBufferingPolicy(1, 0) if inspected else None) + ) + dispatches = [] + async with proxy(openai_api_key, checker, dispatches) as client: + response = await client.post("/v1/responses", json=request) + assert_consumed_once(vcr) + assert len(dispatches) == 1 + assert json.loads(dispatches[0].body) == request + assert response.status_code == 200 + assert response.content == upstream.content + assert response.headers["content-type"] == upstream.headers["content-type"] + assert checker.text_calls == ( + [("input", INPUT)] + [("output", delta) for delta in (deltas if stream else [text])] if inspected else [] + ) + + +@pytest.mark.parametrize("stream", [False, True], ids=["buffered", "streaming"]) +async def test_native_provider_error_is_preserved(openai_api_key, record_mode, recorded_cassette_path, vcr, stream): + request = responses_request(stream, model="recorded-invalid-responses-model") + upstream = await capture(openai_api_key, request) + assert upstream.status_code == 404 + assert upstream.json()["error"]["code"] == "model_not_found" + if record_mode != "none": + assert len(vcr.requests) == 1 + return + + assert_consumed_once(vcr) + assert cassette_request_jsons(recorded_cassette_path) == [request] + vcr.rewind() + checker = StaticChecker(ContentInspectionPolicy(True, True, StreamBufferingPolicy(1, 0))) + dispatches = [] + async with proxy(openai_api_key, checker, dispatches) as client: + response = await client.post("/v1/responses", json=request) + assert_consumed_once(vcr) + assert len(dispatches) == 1 + assert response.status_code == upstream.status_code + assert response.content == upstream.content + assert checker.text_calls == [("input", INPUT)] + + +@pytest.mark.vcr(BUFFERED_RECORDING, record_mode="none") +async def test_recorded_buffered_output_is_withheld_on_block(openai_api_key, vcr): + upstream = await capture(openai_api_key, responses_request(False)) + assert_consumed_once(vcr) + text = assistant_text(upstream.json()) + vcr.rewind() + checker = StaticChecker(ContentInspectionPolicy(True, True), output_decisions=[ContentBlocked("blocked by test")]) + dispatches = [] + async with proxy(openai_api_key, checker, dispatches) as client: + response = await client.post("/v1/responses", json=responses_request(False)) + assert_consumed_once(vcr) + assert len(dispatches) == 1 + assert response.status_code == 400 + assert response.json()["error"]["code"] == "content_blocked" + assert text not in response.text + assert checker.text_calls == [("input", INPUT), ("output", text)] + + +@pytest.mark.vcr(STREAMING_RECORDING, record_mode="none") +@pytest.mark.parametrize("allowed_windows", [0, 1], ids=["first-window", "later-window"]) +@pytest.mark.parametrize("chunk_size,context_size", [(1, 0), (2, 1)], ids=["single-delta", "overlapping-context"]) +async def test_recorded_stream_releases_only_approved_prefix( + openai_api_key, vcr, allowed_windows, chunk_size, context_size +): + upstream = await capture(openai_api_key, responses_request(True)) + assert_consumed_once(vcr) + events = await stream_events(upstream.content) + text_events = [ + (index, payload["delta"]) + for index, event in enumerate(events) + if (payload := json.loads(event.data))["type"] == "response.output_text.delta" and payload["delta"] + ] + assert len(text_events) > (allowed_windows + 1) * chunk_size + approved_deltas = allowed_windows * chunk_size + released_end = text_events[approved_deltas - 1][0] + 1 if approved_deltas else 0 + checker = StaticChecker( + ContentInspectionPolicy(True, True, StreamBufferingPolicy(chunk_size, context_size)), + output_decisions=[ContentAllowed()] * allowed_windows + [ContentBlocked("blocked by test")], + ) + vcr.rewind() + dispatches = [] + async with proxy(openai_api_key, checker, dispatches) as client: + response = await client.post("/v1/responses", json=responses_request(True)) + assert_consumed_once(vcr) + assert len(dispatches) == 1 + assert response.status_code == 200 + received = await stream_events(response.content) + assert [event.raw for event in received[:-1]] == [event.raw for event in events[:released_end]] + assert received[-1].event_type == b"error" + assert json.loads(received[-1].data)["code"] == "content_blocked" + assert b"[DONE]" not in response.content + assert checker.text_calls == [("input", INPUT)] + [ + ( + "output", + "".join( + text for _, text in text_events[max(0, window * chunk_size - context_size) : (window + 1) * chunk_size] + ), + ) + for window in range(allowed_windows + 1) + ] diff --git a/tests/server/experimental/responses_proxy.py b/tests/server/experimental/responses_proxy.py new file mode 100644 index 0000000000..fdf73d7c0f --- /dev/null +++ b/tests/server/experimental/responses_proxy.py @@ -0,0 +1,66 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from contextlib import asynccontextmanager + +import httpx +from fastapi import FastAPI + +from nemoguardrails.server.experimental._guarded_proxy import create_guarded_http_operation +from nemoguardrails.server.experimental._http_kernel import create_http_proxy_router +from nemoguardrails.server.experimental._provider_transport import ProviderTransport +from nemoguardrails.server.experimental.provider.integration import UpstreamSettings +from nemoguardrails.server.experimental.providers.openai.errors import OPENAI_ERROR_MAPPING +from nemoguardrails.server.experimental.providers.openai.responses.endpoint import RESPONSES_ENDPOINT + + +@asynccontextmanager +async def responses_proxy(api_key, checker, dispatches, *, http_transport=None): + async with httpx.AsyncClient(timeout=60, transport=http_transport) as upstream: + transport = ProviderTransport( + UpstreamSettings(base_url="https://api.openai.com/v1", mount_path="/v1"), upstream + ) + + async def buffered(request): + dispatches.append(request) + return await transport.dispatch(request, require_identity_response=True) + + async def streaming(request): + dispatches.append(request) + return await transport.stream_dispatch(request, require_identity_response=True) + + app = FastAPI() + app.include_router( + create_http_proxy_router( + operations=( + create_guarded_http_operation( + RESPONSES_ENDPOINT, + OPENAI_ERROR_MAPPING, + stream_dispatch=streaming, + max_stream_event_bytes=1024 * 1024, + max_pending_stream_bytes=1024 * 1024, + ), + ), + checker=checker, + dispatch=buffered, + render_outcome=OPENAI_ERROR_MAPPING.renderer, + ) + ) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://recorded-responses.test", + headers={"authorization": f"Bearer {api_key}"}, + ) as client: + yield client From fb0ca7507d5659f19fbc17bf92a7dad01dca979b Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:45:14 +0200 Subject: [PATCH 08/11] fix(server): validate variant discriminator types before coercion Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- .../server/experimental/contracts/README.md | 4 + .../experimental/provider/contract_export.py | 14 ++- .../provider/projection_policy.py | 11 ++ .../experimental/test_projection_variants.py | 111 +++++++++++++++++- 4 files changed, 135 insertions(+), 5 deletions(-) diff --git a/nemoguardrails/server/experimental/contracts/README.md b/nemoguardrails/server/experimental/contracts/README.md index dcfa437e03..e218fc4857 100644 --- a/nemoguardrails/server/experimental/contracts/README.md +++ b/nemoguardrails/server/experimental/contracts/README.md @@ -90,6 +90,10 @@ Object `variant` metadata describes discriminator matching and selected-variant cardinality. This is distinct from ordinary array length constraints. Typed `ObjectPolicy.variant` declarations derive the runtime array selectors; required literal discriminators must distinguish the reviewed object branches. +Runtime validation checks discriminator types before coercion so selection in +the parsed model agrees with selection in the original payload. Integer `1`, +boolean `true`, and floating-point `1.0` are not interchangeable selectors. +Generic JSON Schema does not express this Python type strictness. For example, Responses can retain reasoning items while requiring exactly one assistant message. Generic JSON Schema validators ignore this guard annotation; they do not enforce selected-variant cardinality without policy-aware handling. diff --git a/nemoguardrails/server/experimental/provider/contract_export.py b/nemoguardrails/server/experimental/provider/contract_export.py index ab7c56811c..4a2af224dc 100644 --- a/nemoguardrails/server/experimental/provider/contract_export.py +++ b/nemoguardrails/server/experimental/provider/contract_export.py @@ -74,7 +74,11 @@ def visit(node: Any) -> None: "function-wrap", "function-plain", }: - if node["function"]["function"] is not PolicyModel.reject_unreviewed_fields: + function = node["function"]["function"] + if getattr(function, "__func__", function) not in { + PolicyModel.reject_unreviewed_fields, + PolicyModel.reject_coerced_variant_discriminators.__func__, + }: raise ValueError("Custom stream validators cannot be represented by the contract") for name, child in node.items(): if isinstance(kind, str) and name in {"metadata", "default", "config", "function"}: @@ -202,8 +206,12 @@ def _validate_stream_selector(model: type[GuardedRequestModel]) -> None: decorators = model.__pydantic_decorators__ if any(selector in item.info.fields or "*" in item.info.fields for item in decorators.field_validators.values()): raise ValueError("Custom stream selector validators cannot be represented by the contract") - allowed = {PolicyModel.reject_unreviewed_fields, GuardedPayloadModel.validate_guarded_text_target} - if any(item.func not in allowed for item in decorators.model_validators.values()): + allowed = { + PolicyModel.reject_unreviewed_fields, + PolicyModel.reject_coerced_variant_discriminators.__func__, + GuardedPayloadModel.validate_guarded_text_target, + } + if any(getattr(item.func, "__func__", item.func) not in allowed for item in decorators.model_validators.values()): raise ValueError("Custom response-mode validation cannot be represented by the declared stream selector") if field.default_factory is not None: raise ValueError("A stream selector default factory cannot be represented by the contract") diff --git a/nemoguardrails/server/experimental/provider/projection_policy.py b/nemoguardrails/server/experimental/provider/projection_policy.py index fe2e047be9..5181dc18bf 100644 --- a/nemoguardrails/server/experimental/provider/projection_policy.py +++ b/nemoguardrails/server/experimental/provider/projection_policy.py @@ -22,6 +22,7 @@ from __future__ import annotations import re +from collections.abc import Mapping from dataclasses import dataclass from types import UnionType from typing import Annotated, Any, ClassVar, Literal, Union, cast, get_args, get_origin @@ -194,6 +195,16 @@ def __get_pydantic_json_schema__(cls, core_schema: CoreSchema, handler: GetJsonS node[EXTENSION] = metadata return node + @model_validator(mode="before") + @classmethod + def reject_coerced_variant_discriminators(cls, value: Any) -> Any: + """Keep parsed variant selection consistent with the original payload.""" + if cls.policy.variant is not None and isinstance(value, Mapping): + for name, expected in cls.policy.variant.match: + if name in value and type(value[name]) is not type(expected): + raise ValueError(f"Variant discriminator {name!r} requires its declared literal type") + return value + @model_validator(mode="after") def reject_unreviewed_fields(self, info: ValidationInfo) -> Self: """Reject members outside the reviewed fields unless policy allows them. diff --git a/tests/server/experimental/test_projection_variants.py b/tests/server/experimental/test_projection_variants.py index 99001b6f50..ca91303965 100644 --- a/tests/server/experimental/test_projection_variants.py +++ b/tests/server/experimental/test_projection_variants.py @@ -19,9 +19,13 @@ from typing import Annotated, ClassVar, Literal import pytest -from pydantic import ValidationError +from pydantic import StrictBool, ValidationError -from nemoguardrails.server.experimental.provider.payload import GuardedArraySelection, GuardedPayloadModel +from nemoguardrails.server.experimental.provider.payload import ( + GuardedArraySelection, + GuardedPayloadModel, + GuardedRequestModel, +) from nemoguardrails.server.experimental.provider.projection_policy import ( EXTENSION, ArrayVariant, @@ -244,3 +248,106 @@ class Invalid(PolicyModel): for derive in (text_location, stream_text_path): with pytest.raises(ValueError, match="hide guarded"): derive(Invalid) + + +@pytest.fixture(params=[(Literal[1], 1), (Literal[True], True), (Literal[False], False)]) +def scalar_variant_request(request): + annotation, expected = request.param + + class ScalarMessage(PolicyModel): + policy: ClassVar[ObjectPolicy] = ObjectPolicy( + variant=ArrayVariant((("type", "message"), ("kind", expected), ("revision", 2)), "exactly_one") + ) + type: Annotated[Literal["message"], constrained()] + kind: Annotated[annotation, constrained()] + revision: Annotated[Literal[2], constrained()] + text: Annotated[str, guarded("user", replaceable=False, min_length=1)] + + class Request(PolicyModel, GuardedRequestModel): + output: Annotated[list[MetadataItem | ScalarMessage], guarded()] + stream: Annotated[StrictBool, constrained()] = False + stream_selector_field = "stream" + + Request.guarded_text_location = text_location(Request) + Request.projection_contract = payload_contract(Request, projection_id="scalar.request", direction="request") + return Request, expected + + +def test_scalar_variant_validation_preserves_original_target(scalar_variant_request): + model, expected = scalar_variant_request + item = {"type": "message", "kind": expected, "revision": 2, "text": "answer"} + document = {"output": [{"type": "metadata"}, item]} + projection = model.validate_payload(document) + target = projection.locate_guarded_message(document) + assert target.message.content == "answer" + item["text"] = "updated" + assert target.message.content == "updated" + assert type(item["kind"]) is type(expected) + assert model.model_validate(projection) is projection + + +def test_scalar_variant_rejects_coercion_before_extraction(scalar_variant_request): + model, expected = scalar_variant_request + for value in (True, False, 0, 1, 0.0, 1.0, "1", "true", None): + if type(value) is type(expected) and value == expected: + continue + document = {"output": [{"type": "message", "kind": value, "revision": 2, "text": "answer"}]} + with pytest.raises(ValidationError): + model.validate_payload(document) + + +@pytest.mark.parametrize("revision", [2.0, "2", True, None]) +def test_variant_checks_each_discriminator(scalar_variant_request, revision): + model, expected = scalar_variant_request + with pytest.raises(ValidationError): + model.validate_payload( + {"output": [{"type": "message", "kind": expected, "revision": revision, "text": "answer"}]} + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("value", [True, 1.0]) +async def test_coerced_variant_request_returns_unsupported_shape(scalar_variant_request, value): + import httpx + from fastapi import FastAPI + + from nemoguardrails.server.experimental._content_checker import ContentInspectionPolicy + from nemoguardrails.server.experimental._guarded_proxy import create_guarded_http_operation + from nemoguardrails.server.experimental._http_kernel import create_http_proxy_router + from nemoguardrails.server.experimental.providers.openai.errors import OPENAI_ERROR_MAPPING + from nemoguardrails.server.experimental.providers.openai.responses.endpoint import RESPONSES_ENDPOINT + from tests.server.experimental.checkers import StaticChecker + + model, expected = scalar_variant_request + if type(value) is type(expected) and value == expected: + value = 1 + endpoint = replace(RESPONSES_ENDPOINT, guarded_request_model=model) + inspector = StaticChecker(ContentInspectionPolicy(True, True)) + + async def dispatch(request): + raise AssertionError("Invalid discriminator must not reach the provider") + + app = FastAPI() + app.include_router( + create_http_proxy_router( + operations=( + create_guarded_http_operation( + endpoint, + OPENAI_ERROR_MAPPING, + max_stream_event_bytes=1024, + max_pending_stream_bytes=1024, + ), + ), + checker=inspector, + dispatch=dispatch, + render_outcome=OPENAI_ERROR_MAPPING.renderer, + ) + ) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://proxy.test") as client: + response = await client.post( + "/v1/responses", + json={"output": [{"type": "message", "kind": value, "revision": 2, "text": "answer"}]}, + ) + assert response.status_code == 422 + assert response.json()["error"]["code"] == "unsupported_responses_shape" + assert inspector.calls == [] From 451314ba4ea598975a713f5876f3c656c2da660d Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:45:26 +0200 Subject: [PATCH 09/11] test(server): characterize stored Responses retrieval scope Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- .../providers/openai/responses/README.md | 29 +++++++ ..._openai_responses_streaming_integration.py | 83 +++++++++++++++++++ 2 files changed, 112 insertions(+) diff --git a/nemoguardrails/server/experimental/providers/openai/responses/README.md b/nemoguardrails/server/experimental/providers/openai/responses/README.md index b53a31352d..8594dd2131 100644 --- a/nemoguardrails/server/experimental/providers/openai/responses/README.md +++ b/nemoguardrails/server/experimental/providers/openai/responses/README.md @@ -120,6 +120,35 @@ Disabling those routes is a separate deployment policy. Deployment authentication and socket-level acceptance belong to the application/example work, not these declarations. +### Stored responses and enforcement scope + +Request `store` is forwarded unchanged and remains uninspected. OpenAI +[defaults omitted `store` to true](https://developers.openai.com/api/reference/python/resources/responses/methods/create), +allowing later API retrieval. An approved streaming prefix can expose the +response ID before a later output window is blocked. Blocking withholds the +pending stream content; it does not delete upstream state or guarantee that +closing the stream cancels provider generation or storage. + +`GET /v1/responses/{response_id}` is an unguarded passthrough route. If the +provider retained the response and authorizes retrieval, that route can return +content withheld by the POST output check. This applies even when Responses is +selected for guarding. The offline storage characterization test models a +retained response; it does not establish how a live provider stores every +interrupted foreground stream. + +Applications using this proxy as an enforcement boundary must restrict exposed +routes, including retrieval and other unguarded generation operations, and +control direct upstream access. Apply that policy before forwarding requests +with application-owned credentials. Caller-owned upstream credentials may also +allow callers to bypass the proxy directly. Retrieval restrictions must cover +existing stored resources as well as newly created responses. + +Applications may additionally require explicit `store: false` when persistent +responses are unwanted. A projection default does not insert that field into +forwarded bytes, and disabling storage for new requests does not restrict access +to existing resources or other passthrough operations. Storage settings alone +therefore do not provide an application-wide enforcement boundary. + ## Contract export and verification ```bash diff --git a/tests/server/experimental/test_openai_responses_streaming_integration.py b/tests/server/experimental/test_openai_responses_streaming_integration.py index 0c9304cf51..8fb29ca84c 100644 --- a/tests/server/experimental/test_openai_responses_streaming_integration.py +++ b/tests/server/experimental/test_openai_responses_streaming_integration.py @@ -15,6 +15,8 @@ """Exercise Responses through the existing HTTP and streaming kernels.""" +import json + import httpx import pytest from fastapi import FastAPI @@ -27,10 +29,13 @@ ) from nemoguardrails.server.experimental._guarded_proxy import create_guarded_http_operation from nemoguardrails.server.experimental._http_kernel import create_http_proxy_router +from nemoguardrails.server.experimental._provider_routes import include_provider_routes from nemoguardrails.server.experimental._streaming_http import StreamingHttpResponse from nemoguardrails.server.experimental.providers.openai.errors import OPENAI_ERROR_MAPPING +from nemoguardrails.server.experimental.providers.openai.integration import create_openai_integration from nemoguardrails.server.experimental.providers.openai.responses.endpoint import RESPONSES_ENDPOINT from tests.server.experimental.test_openai_chat_streaming_integration import Source, StaticChecker +from tests.server.experimental.test_openai_responses_integration import ResponseBody from tests.server.experimental.test_openai_responses_projections import response_payload from tests.server.experimental.test_openai_responses_streaming_projections import event_bytes @@ -170,3 +175,81 @@ async def test_upstream_failure_closes_owner_and_withholds_pending_text(): assert b"private detail" not in response.content assert b"upstream_stream_failed" in response.content assert source.closed + + +@pytest.mark.asyncio +@pytest.mark.parametrize("storage_fields", [{}, {"store": True}], ids=["default-storage", "explicit-storage"]) +async def test_stored_response_retrieval_passes_through_after_later_stream_output_is_blocked(storage_fields): + response_id = "resp_stored" + accepted_prefix = event_bytes("response.created", response={"id": response_id, "output": []}) + event_bytes( + "response.output_text.delta", delta="safe prefix " + ) + stored_response = {**response_payload("safe prefix BLOCK_OUTPUT"), "id": response_id, "store": True} + stored_wire = json.dumps(stored_response, indent=2).encode() + retrieval_stream = ResponseBody(stored_wire) + stream = ResponseBody( + accepted_prefix + + event_bytes("response.output_text.delta", delta="BLOCK_OUTPUT") + + event_bytes("response.completed", response=stored_response) + ) + inspector = StaticChecker( + ContentInspectionPolicy(True, True, StreamBufferingPolicy(1, 0)), + output_decisions=[ContentAllowed(), ContentBlocked("blocked")], + ) + requests = [] + resolutions = [] + + async def handler(request): + requests.append(request) + if request.method == "POST": + assert request.url.path == "/v1/responses" + return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=stream) + assert request.method == "GET" + assert request.url.path == f"/v1/responses/{response_id}" + return httpx.Response(200, headers={"content-type": "application/json"}, stream=retrieval_stream) + + async def resolve_checker(request, provider_type, provider_instance_id, operation_name): + resolutions.append((request.method, operation_name)) + return inspector + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as upstream: + application = FastAPI() + include_provider_routes( + application, + (create_openai_integration(base_url="https://provider.test/v1", operations=("responses",)),), + resolve_checker=resolve_checker, + http_client=lambda: upstream, + ) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=application), base_url="http://proxy.test" + ) as client: + request_wire = json.dumps({"input": "question", "stream": True, **storage_fields}, indent=2).encode() + response = await client.post( + "/v1/responses", content=request_wire, headers={"content-type": "application/json"} + ) + assert response.status_code == 200 + assert response.content.startswith(accepted_prefix) + assert response_id.encode() in response.content + assert b'"code":"content_blocked"' in response.content + assert b"BLOCK_OUTPUT" not in response.content + assert b"response.completed" not in response.content + assert stream.closed + assert requests[0].content == request_wire + assert inspector.text_calls == [ + ("input", "question"), + ("output", "safe prefix "), + ("output", "BLOCK_OUTPUT"), + ] + calls_before_retrieval = list(inspector.calls) + policy_reads_before_retrieval = inspector.policy_reads + + retrieved = await client.get(f"/v1/responses/{response_id}") + + assert retrieved.status_code == 200 + assert retrieved.content == stored_wire + assert retrieval_stream.closed + assert b"BLOCK_OUTPUT" in retrieved.content + assert [request.method for request in requests] == ["POST", "GET"] + assert resolutions == [("POST", "responses.create")] + assert inspector.calls == calls_before_retrieval + assert inspector.policy_reads == policy_reads_before_retrieval From 9d4f41ab654c3d1538b0a51ba30e80240c2fb6ea Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:24:37 +0200 Subject: [PATCH 10/11] fix(server): require storage disabled for guarded Responses Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- .../openai/_generated/responses.guard.yaml | 6 +- .../providers/openai/responses/README.md | 48 +++++++------- .../openai/responses/request_projection.py | 2 +- .../test_openai_responses_contract_export.py | 17 +++++ .../test_openai_responses_integration.py | 30 +++++++-- .../test_openai_responses_projections.py | 7 +- ..._openai_responses_streaming_integration.py | 64 ++++--------------- 7 files changed, 85 insertions(+), 89 deletions(-) diff --git a/nemoguardrails/server/experimental/contracts/openai/_generated/responses.guard.yaml b/nemoguardrails/server/experimental/contracts/openai/_generated/responses.guard.yaml index f1fd00907d..b987a11628 100644 --- a/nemoguardrails/server/experimental/contracts/openai/_generated/responses.guard.yaml +++ b/nemoguardrails/server/experimental/contracts/openai/_generated/responses.guard.yaml @@ -144,8 +144,11 @@ request: x-nemo-guardrails: classification: opaque store: + const: false + type: boolean x-nemo-guardrails: - classification: opaque + classification: constrained + reason: core_capability.response_storage stream: default: false type: boolean @@ -235,6 +238,7 @@ request: classification: opaque required: - input + - store title: openai.responses.request.text.v1 type: object x-nemo-guardrails: diff --git a/nemoguardrails/server/experimental/providers/openai/responses/README.md b/nemoguardrails/server/experimental/providers/openai/responses/README.md index 8594dd2131..f0c6fecfda 100644 --- a/nemoguardrails/server/experimental/providers/openai/responses/README.md +++ b/nemoguardrails/server/experimental/providers/openai/responses/README.md @@ -19,6 +19,7 @@ for Responses in both directions. A replacement decision fails explicitly. Request `stream` is a strict boolean, defaulting to false; this boundary rejects null rather than coercing it. `background` permits only the boolean false. +`store` is required and permits only the boolean false in both response modes. Disabled fields accept omission or null, not an empty container or a value such as `tool_choice: none`. `top_logprobs` permits omission, null, or integer zero. Response and event logprobs and annotations must be absent, null, or empty: @@ -122,32 +123,27 @@ belong to the application/example work, not these declarations. ### Stored responses and enforcement scope -Request `store` is forwarded unchanged and remains uninspected. OpenAI -[defaults omitted `store` to true](https://developers.openai.com/api/reference/python/resources/responses/methods/create), -allowing later API retrieval. An approved streaming prefix can expose the -response ID before a later output window is blocked. Blocking withholds the -pending stream content; it does not delete upstream state or guarantee that -closing the stream cancels provider generation or storage. - -`GET /v1/responses/{response_id}` is an unguarded passthrough route. If the -provider retained the response and authorizes retrieval, that route can return -content withheld by the POST output check. This applies even when Responses is -selected for guarding. The offline storage characterization test models a -retained response; it does not establish how a live provider stores every -interrupted foreground stream. - -Applications using this proxy as an enforcement boundary must restrict exposed -routes, including retrieval and other unguarded generation operations, and -control direct upstream access. Apply that policy before forwarding requests -with application-owned credentials. Caller-owned upstream credentials may also -allow callers to bypass the proxy directly. Retrieval restrictions must cover -existing stored resources as well as newly created responses. - -Applications may additionally require explicit `store: false` when persistent -responses are unwanted. A projection default does not insert that field into -forwarded bytes, and disabling storage for new requests does not restrict access -to existing resources or other passthrough operations. Storage settings alone -therefore do not provide an application-wide enforcement boundary. +Guarded creation requests must explicitly supply `store: false`. Omission, +true, null, and non-boolean values are rejected before upstream dispatch. +OpenAI [defaults omitted `store` to true](https://developers.openai.com/api/reference/python/resources/responses/methods/create), +so a projection default would not suffice: original request bytes are forwarded +unchanged, and projection defaults are never inserted into them. + +This constraint prevents requesting retrievable response storage through guarded +creation. It relies on the provider honoring `store: false`; it is not a guarantee +about provider logging or data retention. Blocking output cannot undo upstream +storage, and closing a stream does not guarantee cancellation of generation. + +`GET /v1/responses/{response_id}` remains an unguarded passthrough route, including +for responses stored before this constraint or created elsewhere. Guarding +retrieval is follow-up work needed before relaxing the creation constraint; +other operations exposing stored content also require coverage review. + +Applications using the proxy as an enforcement boundary must restrict exposed +unguarded routes and control direct upstream access. Apply that policy before +forwarding requests with application-owned credentials. The constraint applies +only when Responses creation is selected for guarding; storage settings alone +do not provide an application-wide enforcement boundary. ## Contract export and verification diff --git a/nemoguardrails/server/experimental/providers/openai/responses/request_projection.py b/nemoguardrails/server/experimental/providers/openai/responses/request_projection.py index b9801c7801..59b9ec32c6 100644 --- a/nemoguardrails/server/experimental/providers/openai/responses/request_projection.py +++ b/nemoguardrails/server/experimental/providers/openai/responses/request_projection.py @@ -76,7 +76,6 @@ class ResponsesGuardedRequestProjection(PolicyModel, GuardedRequestModel): "prompt_cache_retention", "safety_identifier", "service_tier", - "store", "stream_options", "temperature", "top_p", @@ -85,6 +84,7 @@ class ResponsesGuardedRequestProjection(PolicyModel, GuardedRequestModel): ), ) input: Annotated[str, guarded("user", replaceable=False, min_length=1)] + store: Annotated[StrictFalse, constrained(reason="core_capability.response_storage")] stream: Annotated[StrictBool, constrained()] = False background: Annotated[StrictFalse, constrained(reason="core_capability.background_execution")] = False context_management: Annotated[None, disabled("core_capability.conversation_state")] = None diff --git a/tests/server/experimental/test_openai_responses_contract_export.py b/tests/server/experimental/test_openai_responses_contract_export.py index 777856719f..f226fb25ef 100644 --- a/tests/server/experimental/test_openai_responses_contract_export.py +++ b/tests/server/experimental/test_openai_responses_contract_export.py @@ -89,3 +89,20 @@ def test_stream_export_preserves_all_bound_event_selections_and_missing_text_rul "sentinels": {"[DONE]": "done"}, } assert "hooks" not in exported["integration"] + + +@pytest.mark.parametrize( + "storage_fields", [{}, {"store": False}, {"store": True}, {"store": None}, {"store": 0}, {"store": "false"}] +) +def test_exported_storage_constraint_matches_runtime(storage_fields, export_policy_validator): + document = {"input": "question", **storage_fields} + validator = export_policy_validator( + export_guard_contract(RESPONSES_ENDPOINT)["request"], UnknownContentFieldPolicy.FORBID + ) + accepted = storage_fields == {"store": False} and type(storage_fields.get("store")) is bool + assert validator.is_valid(document) is accepted + if accepted: + RESPONSES_ENDPOINT.guarded_request_model.validate_payload(document) + else: + with pytest.raises(ValidationError): + RESPONSES_ENDPOINT.guarded_request_model.validate_payload(document) diff --git a/tests/server/experimental/test_openai_responses_integration.py b/tests/server/experimental/test_openai_responses_integration.py index 51f609d951..d2b77ea372 100644 --- a/tests/server/experimental/test_openai_responses_integration.py +++ b/tests/server/experimental/test_openai_responses_integration.py @@ -229,7 +229,11 @@ def test_operation_selection_guards_selected_routes_and_preserves_other_passthro text = "BLOCK_INPUT" if blocked_stage == "input" else "question" if blocked_stage == "output": settings["output"] = "BLOCK_OUTPUT" - subject = {"input": text} if operation == "responses" else {"messages": [{"role": "user", "content": text}]} + subject = ( + {"input": text, "store": False} + if operation == "responses" + else {"messages": [{"role": "user", "content": text}]} + ) response = client.post("/openai/v1/" + operation, json={"stream": streaming, **subject}) if guarded: assert response.status_code == (200 if streaming and blocked_stage == "output" else 400) @@ -249,7 +253,9 @@ def test_operation_selection_guards_selected_routes_and_preserves_other_passthro def test_both_operations_work_through_the_existing_application(proxy, streaming, operation): client, requests, bodies, _ = proxy subject = ( - {"input": "question"} if operation == "responses" else {"messages": [{"role": "user", "content": "question"}]} + {"input": "question", "store": False} + if operation == "responses" + else {"messages": [{"role": "user", "content": "question"}]} ) body = json.dumps({"model": "test-model", "stream": streaming, **subject}, indent=2).encode() response = client.post( @@ -267,7 +273,7 @@ def test_both_operations_work_through_the_existing_application(proxy, streaming, @pytest.mark.parametrize("streaming", [False, True]) def test_real_input_rail_blocks_before_dispatch(proxy, streaming): client, requests, _, _ = proxy - response = client.post("/openai/v1/responses", json={"input": "BLOCK_INPUT", "stream": streaming}) + response = client.post("/openai/v1/responses", json={"input": "BLOCK_INPUT", "store": False, "stream": streaming}) assert response.status_code == 400 assert response.json()["error"]["code"] == "content_blocked" assert not requests @@ -277,7 +283,7 @@ def test_real_input_rail_blocks_before_dispatch(proxy, streaming): def test_real_output_rail_withholds_blocked_response_and_stream_snapshots(proxy, streaming): client, _, bodies, settings = proxy settings["output"] = "BLOCK_OUTPUT" - response = client.post("/openai/v1/responses", json={"input": "question", "stream": streaming}) + response = client.post("/openai/v1/responses", json={"input": "question", "store": False, "stream": streaming}) assert response.status_code == (200 if streaming else 400) assert b"content_blocked" in response.content assert b"BLOCK_OUTPUT" not in response.content @@ -291,7 +297,7 @@ def test_real_output_rail_withholds_blocked_response_and_stream_snapshots(proxy, def test_provider_failure_bytes_and_headers_are_not_rewritten(proxy, streaming): client, _, bodies, settings = proxy settings["status"] = 429 - response = client.post("/openai/v1/responses", json={"input": "question", "stream": streaming}) + response = client.post("/openai/v1/responses", json={"input": "question", "store": False, "stream": streaming}) assert response.status_code == 429 assert response.content == bodies[0].body assert response.headers["x-request-id"] == "synthetic" @@ -331,7 +337,7 @@ def forward(request): base_url="https://sdk.test/v1", http_client=httpx.Client(transport=httpx.MockTransport(forward)), ) as sdk: - events = list(sdk.responses.create(model="test-model", input="question", stream=True)) + events = list(sdk.responses.create(model="test-model", input="question", store=False, stream=True)) if blocked: assert events[-1].type == "error" assert events[-1].code == "content_blocked" @@ -339,3 +345,15 @@ def forward(request): else: assert events[-1].type == "response.completed" assert "".join(item.delta for item in events if item.type == "response.output_text.delta") == "answer" + + +@pytest.mark.parametrize("streaming", [False, True]) +@pytest.mark.parametrize( + "storage_fields", [{}, {"store": True}, {"store": None}, {"store": 0}, {"store": 0.0}, {"store": "false"}] +) +def test_storage_requires_explicit_false_before_upstream_dispatch(proxy, streaming, storage_fields): + client, requests, _, _ = proxy + response = client.post("/openai/v1/responses", json={"input": "question", "stream": streaming, **storage_fields}) + assert response.status_code == 422 + assert response.json()["error"]["code"] == "unsupported_responses_shape" + assert not requests diff --git a/tests/server/experimental/test_openai_responses_projections.py b/tests/server/experimental/test_openai_responses_projections.py index 8294e64e8c..12cea7dff0 100644 --- a/tests/server/experimental/test_openai_responses_projections.py +++ b/tests/server/experimental/test_openai_responses_projections.py @@ -53,6 +53,7 @@ def test_request_extracts_original_input_without_modifying_provider_fields(): payload = { "model": "test-model", "input": "hello", + "store": False, "reasoning": {"effort": "medium"}, "text": {"format": {"type": "text"}, "verbosity": "medium"}, } @@ -93,12 +94,14 @@ def test_request_extracts_original_input_without_modifying_provider_fields(): ) def test_unsupported_requests_fail_closed(updates): with pytest.raises(ValidationError): - ResponsesGuardedRequest.validate_payload({"input": "hello", **updates}) + ResponsesGuardedRequest.validate_payload({"input": "hello", "store": False, **updates}) @pytest.mark.parametrize("stream", [False, True]) def test_request_selects_response_mode_from_a_strict_boolean(stream): - request = ResponsesGuardedRequest.validate_payload({"input": "hello", "stream": stream, "top_logprobs": 0}) + request = ResponsesGuardedRequest.validate_payload( + {"input": "hello", "store": False, "stream": stream, "top_logprobs": 0} + ) assert request.streams_response is stream diff --git a/tests/server/experimental/test_openai_responses_streaming_integration.py b/tests/server/experimental/test_openai_responses_streaming_integration.py index 8fb29ca84c..9df5ba4661 100644 --- a/tests/server/experimental/test_openai_responses_streaming_integration.py +++ b/tests/server/experimental/test_openai_responses_streaming_integration.py @@ -71,7 +71,9 @@ async def reject_buffered(request): async with httpx.AsyncClient( transport=httpx.ASGITransport(app=application), base_url="http://proxy.test" ) as client: - result = await client.post("/v1/responses", json={"model": "test-model", "input": "question", "stream": True}) + result = await client.post( + "/v1/responses", json={"model": "test-model", "input": "question", "store": False, "stream": True} + ) return result, source, dispatches @@ -178,39 +180,17 @@ async def test_upstream_failure_closes_owner_and_withholds_pending_text(): @pytest.mark.asyncio -@pytest.mark.parametrize("storage_fields", [{}, {"store": True}], ids=["default-storage", "explicit-storage"]) -async def test_stored_response_retrieval_passes_through_after_later_stream_output_is_blocked(storage_fields): - response_id = "resp_stored" - accepted_prefix = event_bytes("response.created", response={"id": response_id, "output": []}) + event_bytes( - "response.output_text.delta", delta="safe prefix " - ) - stored_response = {**response_payload("safe prefix BLOCK_OUTPUT"), "id": response_id, "store": True} - stored_wire = json.dumps(stored_response, indent=2).encode() +async def test_preexisting_stored_response_retrieval_remains_unguarded(): + stored_wire = json.dumps({**response_payload("BLOCK_OUTPUT"), "store": True}).encode() retrieval_stream = ResponseBody(stored_wire) - stream = ResponseBody( - accepted_prefix - + event_bytes("response.output_text.delta", delta="BLOCK_OUTPUT") - + event_bytes("response.completed", response=stored_response) - ) - inspector = StaticChecker( - ContentInspectionPolicy(True, True, StreamBufferingPolicy(1, 0)), - output_decisions=[ContentAllowed(), ContentBlocked("blocked")], - ) requests = [] - resolutions = [] async def handler(request): requests.append(request) - if request.method == "POST": - assert request.url.path == "/v1/responses" - return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=stream) - assert request.method == "GET" - assert request.url.path == f"/v1/responses/{response_id}" return httpx.Response(200, headers={"content-type": "application/json"}, stream=retrieval_stream) - async def resolve_checker(request, provider_type, provider_instance_id, operation_name): - resolutions.append((request.method, operation_name)) - return inspector + async def resolve_checker(*args): + raise AssertionError("Retrieval is not a guarded operation") async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as upstream: application = FastAPI() @@ -223,33 +203,11 @@ async def resolve_checker(request, provider_type, provider_instance_id, operatio async with httpx.AsyncClient( transport=httpx.ASGITransport(app=application), base_url="http://proxy.test" ) as client: - request_wire = json.dumps({"input": "question", "stream": True, **storage_fields}, indent=2).encode() - response = await client.post( - "/v1/responses", content=request_wire, headers={"content-type": "application/json"} - ) - assert response.status_code == 200 - assert response.content.startswith(accepted_prefix) - assert response_id.encode() in response.content - assert b'"code":"content_blocked"' in response.content - assert b"BLOCK_OUTPUT" not in response.content - assert b"response.completed" not in response.content - assert stream.closed - assert requests[0].content == request_wire - assert inspector.text_calls == [ - ("input", "question"), - ("output", "safe prefix "), - ("output", "BLOCK_OUTPUT"), - ] - calls_before_retrieval = list(inspector.calls) - policy_reads_before_retrieval = inspector.policy_reads - - retrieved = await client.get(f"/v1/responses/{response_id}") + retrieved = await client.get("/v1/responses/resp_test") assert retrieved.status_code == 200 assert retrieved.content == stored_wire assert retrieval_stream.closed - assert b"BLOCK_OUTPUT" in retrieved.content - assert [request.method for request in requests] == ["POST", "GET"] - assert resolutions == [("POST", "responses.create")] - assert inspector.calls == calls_before_retrieval - assert inspector.policy_reads == policy_reads_before_retrieval + assert len(requests) == 1 + assert requests[0].method == "GET" + assert requests[0].url.path == "/v1/responses/resp_test" From 09f6799daca9c4270e67af0696818a9967cc5f46 Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:33:50 +0200 Subject: [PATCH 11/11] refactor(server): centralize validator function identity checks Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- .../experimental/provider/contract_export.py | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/nemoguardrails/server/experimental/provider/contract_export.py b/nemoguardrails/server/experimental/provider/contract_export.py index 4a2af224dc..1116da6844 100644 --- a/nemoguardrails/server/experimental/provider/contract_export.py +++ b/nemoguardrails/server/experimental/provider/contract_export.py @@ -62,6 +62,10 @@ from nemoguardrails.server.experimental.provider.stream_policy import build_policy_stream_classifier +def _validator_function(validator: Any) -> Any: + return getattr(validator, "__func__", validator) + + def _validate_stream_model(model: type[PolicyModel]) -> None: """Reject custom stream validation that cannot be described by field policy.""" @@ -75,9 +79,9 @@ def visit(node: Any) -> None: "function-plain", }: function = node["function"]["function"] - if getattr(function, "__func__", function) not in { - PolicyModel.reject_unreviewed_fields, - PolicyModel.reject_coerced_variant_discriminators.__func__, + if _validator_function(function) not in { + _validator_function(PolicyModel.reject_unreviewed_fields), + _validator_function(PolicyModel.reject_coerced_variant_discriminators), }: raise ValueError("Custom stream validators cannot be represented by the contract") for name, child in node.items(): @@ -89,7 +93,7 @@ def visit(node: Any) -> None: visit(child) for nested in model_graph(model).values(): - if nested.model_validate.__func__ is not PolicyModel.model_validate.__func__: + if _validator_function(nested.model_validate) is not _validator_function(PolicyModel.model_validate): raise ValueError("Custom stream validators cannot be represented by the contract") if any(field.default_factory is not None for field in nested.model_fields.values()): raise ValueError("Stream default factories cannot be represented by the contract") @@ -207,11 +211,11 @@ def _validate_stream_selector(model: type[GuardedRequestModel]) -> None: if any(selector in item.info.fields or "*" in item.info.fields for item in decorators.field_validators.values()): raise ValueError("Custom stream selector validators cannot be represented by the contract") allowed = { - PolicyModel.reject_unreviewed_fields, - PolicyModel.reject_coerced_variant_discriminators.__func__, - GuardedPayloadModel.validate_guarded_text_target, + _validator_function(PolicyModel.reject_unreviewed_fields), + _validator_function(PolicyModel.reject_coerced_variant_discriminators), + _validator_function(GuardedPayloadModel.validate_guarded_text_target), } - if any(getattr(item.func, "__func__", item.func) not in allowed for item in decorators.model_validators.values()): + if any(_validator_function(item.func) not in allowed for item in decorators.model_validators.values()): raise ValueError("Custom response-mode validation cannot be represented by the declared stream selector") if field.default_factory is not None: raise ValueError("A stream selector default factory cannot be represented by the contract")