diff --git a/nemoguardrails/server/experimental/_guarded_operation.py b/nemoguardrails/server/experimental/_guarded_operation.py index 1aaba3978c..bb3c03223b 100644 --- a/nemoguardrails/server/experimental/_guarded_operation.py +++ b/nemoguardrails/server/experimental/_guarded_operation.py @@ -29,6 +29,25 @@ class UnsupportedGuardedPayload(ValueError): """Report a provider payload outside an operation's guarded projection.""" + def __init__(self, message: str, code: str | None = None): + """Store the stable provider-specific failure code when one is available.""" + super().__init__(message) + self.code = code + + +class InvalidGuardedPayload(UnsupportedGuardedPayload): + """Report malformed provider data that cannot enter guarded validation.""" + + +class UnsupportedGuardedRepresentation(UnsupportedGuardedPayload): + """Report an HTTP representation that the guarded payload cannot inspect.""" + + code: str + + def __init__(self, message: str, code: str): + """Store the required code for an unsupported HTTP representation.""" + super().__init__(message, code) + @dataclass(frozen=True, slots=True) class ContentInspectionNotApplicable: diff --git a/nemoguardrails/server/experimental/_guarded_proxy.py b/nemoguardrails/server/experimental/_guarded_proxy.py new file mode 100644 index 0000000000..c5a774d52b --- /dev/null +++ b/nemoguardrails/server/experimental/_guarded_proxy.py @@ -0,0 +1,194 @@ +# 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 provider declarations with the guarded buffered HTTP pipeline.""" + +from dataclasses import dataclass + +from pydantic import ValidationError + +from nemoguardrails.server.experimental._guarded_operation import ( + BufferedGuardedOperation, + ContentInspectionNotApplicable, + InvalidGuardedPayload, + UnsupportedGuardedPayload, + UnsupportedGuardedRepresentation, +) +from nemoguardrails.server.experimental._http_kernel import ( + BufferedHttpRequest, + BufferedHttpResponse, + GuardedHttpOperation, + GuardedOperationPath, +) +from nemoguardrails.server.experimental._json_payload import InvalidJson, UnsupportedJsonShape, parse_json_object +from nemoguardrails.server.experimental.provider.endpoint import GuardedJsonEndpoint +from nemoguardrails.server.experimental.provider.errors import ProviderErrorMapping +from nemoguardrails.server.experimental.provider.payload import GuardedMessageTarget, guarded_schema_error +from nemoguardrails.server.experimental.provider.projection_policy import PolicyModel, policy_json_schema +from nemoguardrails.server.experimental.provider.transport import ProviderBindingViolation +from nemoguardrails.server.experimental.provider.types import GuardedMessage + + +@dataclass(slots=True) +class GuardableProviderRequest: + """Hold one original HTTP request and its validated exact guarded target.""" + + request: BufferedHttpRequest + target: GuardedMessageTarget + + +def _header(headers: tuple[tuple[bytes, bytes], ...], name: bytes) -> str | None: + """Return the last value of a case-insensitive HTTP header.""" + values = [value.decode("latin-1") for key, value in headers if key.lower() == name] + return values[-1] if values else None + + +def _media_type(value: str | None) -> str | None: + """Return a normalized media type without parameters.""" + return value.split(";", 1)[0].strip().lower() if value is not None else None + + +def _require_json_representation(request: BufferedHttpRequest) -> None: + """Require an unencoded JSON representation for guarded request parsing.""" + if _media_type(_header(request.headers, b"content-type")) != "application/json": + raise UnsupportedGuardedRepresentation( + "Content-Type must be application/json.", + "unsupported_media_type", + ) + content_encoding = _header(request.headers, b"content-encoding") + if content_encoding is not None and content_encoding.strip().lower() not in {"", "identity"}: + raise UnsupportedGuardedRepresentation( + "Encoded request bodies are not supported by the guarded path.", + "unsupported_content_encoding", + ) + + +def _prepare_guardable_request( + request: BufferedHttpRequest, + endpoint: GuardedJsonEndpoint, +) -> GuardableProviderRequest: + """Validate the request and locate its exact guarded text target.""" + if endpoint.api_revision is not None: + try: + endpoint.api_revision.validate(request) + except ProviderBindingViolation as error: + raise UnsupportedGuardedPayload(str(error), error.code) from error + _require_json_representation(request) + try: + payload = parse_json_object(request.body) + except InvalidJson as error: + raise InvalidGuardedPayload("The request body must be valid JSON.") from error + except UnsupportedJsonShape as error: + raise UnsupportedGuardedPayload(str(error), endpoint.unsupported_request_code) from error + try: + projection = endpoint.guarded_request_model.validate_payload(payload) + target = projection.locate_guarded_message(payload) + except ValidationError as error: + raise UnsupportedGuardedPayload( + guarded_schema_error(error, "request"), endpoint.unsupported_request_code + ) from error + if projection.streams_response: + raise UnsupportedGuardedPayload( + "The guarded endpoint does not support streaming responses yet.", endpoint.unsupported_request_code + ) + return GuardableProviderRequest( + request=request, + target=target, + ) + + +def create_buffered_guarded_http_operation( + endpoint: GuardedJsonEndpoint, + errors: ProviderErrorMapping, +) -> GuardedHttpOperation: + """Create one buffered HTTP operation from the provider endpoint contract.""" + + def prepare_request(request: BufferedHttpRequest) -> GuardableProviderRequest: + """Prepare one buffered HTTP request for guarded execution.""" + return _prepare_guardable_request(request, endpoint) + + def project_request(payload: GuardableProviderRequest) -> GuardedMessage: + """Select guarded input text from a prepared provider request.""" + return payload.target.message + + def forward_request(request: GuardableProviderRequest) -> BufferedHttpRequest: + """Forward the original HTTP request without reserializing its body.""" + return request.request + + def project_response( + payload: BufferedHttpResponse, + ) -> GuardedMessage | ContentInspectionNotApplicable: + """Select guarded output text from a successful JSON response.""" + if not 200 <= payload.status_code < 300: + return ContentInspectionNotApplicable() + if _media_type(_header(payload.headers, b"content-type")) != "application/json": + raise UnsupportedGuardedPayload( + "The successful provider response must use application/json.", endpoint.unsupported_response_code + ) + content_encoding = _header(payload.headers, b"content-encoding") + if content_encoding is not None and content_encoding.strip().lower() not in {"", "identity"}: + raise UnsupportedGuardedPayload( + "Encoded successful provider responses are not supported.", endpoint.unsupported_response_code + ) + try: + document = parse_json_object(payload.body) + projection = endpoint.guarded_response_model.validate_payload(document) + target = projection.locate_guarded_message(document) + # Validation has already rejected content in any other reviewed + # field, so a response without text has nothing to inspect. + if not target.has_text: + return ContentInspectionNotApplicable() + return target.message + except ValidationError as error: + raise UnsupportedGuardedPayload( + guarded_schema_error(error, "successful response"), endpoint.unsupported_response_code + ) from error + except (InvalidJson, UnsupportedJsonShape) as error: + raise UnsupportedGuardedPayload(str(error), endpoint.unsupported_response_code) from error + + request_model = endpoint.guarded_request_model + request_schema = ( + policy_json_schema(request_model) + if issubclass(request_model, PolicyModel) + else request_model.model_json_schema() + ) + openapi_extra = { + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": request_schema, + } + }, + } + } + if endpoint.api_revision is not None: + openapi_extra["parameters"] = [endpoint.api_revision.openapi_parameter()] + + operation: BufferedGuardedOperation[GuardableProviderRequest, BufferedHttpResponse] = BufferedGuardedOperation( + name=endpoint.operation_name, + input_projection=project_request, + output_projection=project_response, + ) + + return GuardedHttpOperation( + operation_path=GuardedOperationPath(endpoint.route_path, frozenset({endpoint.method})), + operation=operation, + prepare_request=prepare_request, + forward_request=forward_request, + documented_responses=errors.documented_responses, + guarded_operation_paths=endpoint.operation_paths, + openapi_extra=openapi_extra, + ) diff --git a/nemoguardrails/server/experimental/_http_kernel.py b/nemoguardrails/server/experimental/_http_kernel.py index d178888806..508038e262 100644 --- a/nemoguardrails/server/experimental/_http_kernel.py +++ b/nemoguardrails/server/experimental/_http_kernel.py @@ -17,16 +17,18 @@ import re from collections.abc import Awaitable, Callable, Collection, Mapping -from dataclasses import dataclass +from dataclasses import dataclass, replace from enum import Enum +from typing import Any, cast from urllib.parse import quote from fastapi import APIRouter, Request -from starlette.convertors import FloatConvertor, IntegerConvertor, PathConvertor, StringConvertor, UUIDConvertor +from starlette.convertors import FloatConvertor, IntegerConvertor, StringConvertor, UUIDConvertor from starlette.responses import Response from starlette.routing import compile_path from nemoguardrails.server.experimental._buffered_kernel import ( + InspectionStage, OperationBlocked, OperationCheckFailed, OperationCompleted, @@ -39,9 +41,9 @@ _ResolvedContentChecker, validate_content_checker, ) -from nemoguardrails.server.experimental._guarded_operation import BufferedGuardedOperation +from nemoguardrails.server.experimental._guarded_operation import BufferedGuardedOperation, UnsupportedGuardedPayload +from nemoguardrails.server.experimental._http_paths import HTTP_METHODS, GuardedOperationPath -HTTP_METHODS = ("DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT") DEFAULT_MAX_REQUEST_BODY_BYTES = 1024 * 1024 DEFAULT_MAX_RESPONSE_BODY_BYTES = 10 * 1024 * 1024 HttpHeaders = tuple[tuple[bytes, bytes], ...] @@ -144,40 +146,25 @@ def __post_init__(self) -> None: raise TypeError("HTTP response headers must contain byte pairs.") -@dataclass(frozen=True, slots=True) -class GuardedOperationPath: - """Describe a path shape owned by one guarded provider operation.""" - - route_path: str - methods: frozenset[str] = frozenset({"POST"}) - - def __post_init__(self) -> None: - """Validate the route template and its allowed methods.""" - - if not self.route_path.startswith("/") or self.route_path == "/" or self.route_path.endswith("/"): - raise ValueError("A guarded HTTP path must be absolute, non-root, and have no trailing slash.") - try: - _, _, convertors = compile_path(self.route_path) - except (AssertionError, KeyError, ValueError) as error: - raise ValueError("A guarded HTTP path must be a valid route template.") from error - if any(isinstance(convertor, PathConvertor) for convertor in convertors.values()): - raise ValueError("A guarded HTTP path must not contain a path-spanning parameter.") - if not self.methods or any(method not in HTTP_METHODS for method in self.methods): - raise ValueError("Guarded HTTP methods must be supported uppercase methods.") - - def matches(self, path: str) -> bool: - """Return whether a concrete path belongs to this operation.""" - - path_regex, _, _ = compile_path(self.route_path) - return path_regex.fullmatch(path) is not None - - @dataclass(frozen=True, slots=True) class GuardedHttpOperation: """Bind one buffered guarded operation to an owned HTTP path.""" operation_path: GuardedOperationPath - operation: BufferedGuardedOperation[BufferedHttpRequest, BufferedHttpResponse] + operation: BufferedGuardedOperation[Any, BufferedHttpResponse] + prepare_request: Callable[[BufferedHttpRequest], Any] | None = None + forward_request: Callable[[Any], BufferedHttpRequest] | None = None + documented_responses: dict[int | str, dict[str, Any]] | None = None + guarded_operation_paths: tuple[GuardedOperationPath, ...] = () + openapi_extra: dict[str, Any] | None = None + + def __post_init__(self) -> None: + """Validate adapters and derive the default guarded route set.""" + + if (self.prepare_request is None) != (self.forward_request is None): + raise ValueError("Guarded HTTP request preparation and forwarding adapters must be declared together.") + if not self.guarded_operation_paths: + object.__setattr__(self, "guarded_operation_paths", (self.operation_path,)) HttpDispatch = Callable[[BufferedHttpRequest], Awaitable[BufferedHttpResponse]] @@ -251,7 +238,9 @@ def _routes_overlap(left: str, right: str) -> bool: ) -def _validate_operations(operations: Collection[GuardedHttpOperation]) -> tuple[GuardedHttpOperation, ...]: +def _validate_operations( + operations: Collection[GuardedHttpOperation], +) -> tuple[GuardedHttpOperation, ...]: """Require at least one operation with unique names and routes.""" resolved = tuple(operations) @@ -412,6 +401,19 @@ def _render_response(value: BufferedHttpResponse, request_method: str | None = N return response +def _request_identity_encoding(headers: HttpHeaders) -> HttpHeaders: + """Ask the provider for an unencoded response that output checks can read. + + Clients such as the OpenAI SDK accept gzip, and an encoded successful + response cannot be inspected. Without output checks the response is relayed + unchanged, so the client's own preference is kept. + """ + return ( + *((name, value) for name, value in headers if name.lower() != b"accept-encoding"), + (b"accept-encoding", b"identity"), + ) + + def _render_failure( failure: OperationBlocked | OperationCheckFailed @@ -440,10 +442,17 @@ def _guarded_handler( ) -> Callable[[Request], Awaitable[Response]]: """Create the HTTP handler for one guarded operation.""" - async def bounded_dispatch(request: BufferedHttpRequest) -> BufferedHttpResponse: + async def bounded_dispatch(request: Any) -> BufferedHttpResponse: """Dispatch one request and enforce the response body limit.""" - response = await dispatch(request) + forwarded = ( + declaration.forward_request(request) + if declaration.forward_request is not None + else cast(BufferedHttpRequest, request) + ) + if checker.policy.inspect_output: + forwarded = replace(forwarded, headers=_request_identity_encoding(forwarded.headers)) + response = await dispatch(forwarded) if len(response.body) > max_response_body_bytes: raise ResponseBodyTooLarge return response @@ -457,10 +466,21 @@ async def handle(request: Request) -> Response: ) try: buffered_request = await _buffer_request(request, max_request_body_bytes) + try: + operation_request = ( + declaration.prepare_request(buffered_request) + if declaration.prepare_request is not None + else buffered_request + ) + except UnsupportedGuardedPayload as failure: + return _render_failure( + OperationProjectionFailed(InspectionStage.INPUT, failure), + render_outcome, + ) outcome = await execute_buffered_operation( declaration.operation, checker, - buffered_request, + operation_request, bounded_dispatch, ) except RequestBodyTooLarge as failure: @@ -523,7 +543,7 @@ def create_http_proxy_router( router = APIRouter() for declaration in resolved_operations: - guarded_matchers.append(declaration.operation_path) + guarded_matchers.extend(declaration.guarded_operation_paths) router.add_api_route( declaration.operation_path.route_path, _guarded_handler( @@ -538,6 +558,8 @@ def create_http_proxy_router( name=declaration.operation.name, operation_id=declaration.operation.name, response_class=Response, + responses=declaration.documented_responses, + openapi_extra=declaration.openapi_extra, ) @router.api_route("/{path:path}", methods=list(HTTP_METHODS), include_in_schema=False) diff --git a/nemoguardrails/server/experimental/_http_paths.py b/nemoguardrails/server/experimental/_http_paths.py new file mode 100644 index 0000000000..82d65e90a2 --- /dev/null +++ b/nemoguardrails/server/experimental/_http_paths.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. + +"""Guarded HTTP route ownership declarations.""" + +import re +from dataclasses import dataclass + +from starlette.convertors import CONVERTOR_TYPES, PathConvertor + +HTTP_METHODS = ("DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT") + + +_PARAMETER = re.compile(r"{([a-zA-Z_][a-zA-Z0-9_]*)(:[a-zA-Z_][a-zA-Z0-9_]*)?}") + + +def _compile_guarded_path(path: str) -> re.Pattern[str]: + pieces = ["^"] + names = set() + position = 0 + for parameter in _PARAMETER.finditer(path): + name, converter_name = parameter.groups("str") + converter = CONVERTOR_TYPES.get(converter_name.lstrip(":")) + if converter is None or name in names: + raise ValueError("A guarded HTTP path must be a valid route template.") + if isinstance(converter, PathConvertor): + raise ValueError("A guarded HTTP path must not contain a path-spanning parameter.") + names.add(name) + pieces.extend((re.escape(path[position : parameter.start()]), f"(?P<{name}>{converter.regex})")) + position = parameter.end() + pieces.extend((re.escape(path[position:]), "$")) + return re.compile("".join(pieces)) + + +@dataclass(frozen=True, slots=True) +class GuardedOperationPath: + """Describe a path shape owned by one guarded provider operation.""" + + route_path: str + methods: frozenset[str] = frozenset({"POST"}) + + def __post_init__(self) -> None: + """Validate the route template and its allowed methods.""" + + if not self.route_path.startswith("/") or self.route_path == "/" or self.route_path.endswith("/"): + raise ValueError("A guarded HTTP path must be absolute, non-root, and have no trailing slash.") + _compile_guarded_path(self.route_path) + if not self.methods or any(method not in HTTP_METHODS for method in self.methods): + raise ValueError("Guarded HTTP methods must be supported uppercase methods.") + + def matches(self, path: str) -> bool: + """Return whether a concrete path belongs to this operation.""" + + return _compile_guarded_path(self.route_path).fullmatch(path) is not None diff --git a/nemoguardrails/server/experimental/contracts/README.md b/nemoguardrails/server/experimental/contracts/README.md index a25203021c..98b84e823f 100644 --- a/nemoguardrails/server/experimental/contracts/README.md +++ b/nemoguardrails/server/experimental/contracts/README.md @@ -66,21 +66,25 @@ enables a detector or a runtime capability. ## Object and stream metadata `model` identifies the Python model; `source` identifies a provider schema -component. `unknown_fields: configurable` marks an object whose unknown-field -handling depends on runtime validation context. Validation rejects unknown -members by default; only trusted configuration may allow them on such an object. -Objects without the marker always reject them. Generic JSON Schema validators -do not enforce this annotation. Reviewed opaque names appear as properties -classified `opaque`, so `additionalProperties` describes only unreviewed members. - -A member whose name matches a listed property only after Unicode case folding -(`casefold()`), such as `Tools` beside `tools`, is always rejected, even where -unknown members are allowed: some providers match names case-insensitively. -Whitespace remains part of the name. JSON Schema could state this with -`propertyNames`, but the contract vocabulary does not include it, so runtimes -and generators must implement the rule themselves. `additionalProperties` remains the ordinary -schema-level object closure rule. Provider-required opaque fields may be left -to provider validation; a projection is not a full provider request validator. +component. Canonical exports explicitly declare `unknown_fields: forbid` or +`unknown_fields: configurable` on every reviewed object. Both use +`additionalProperties: false` for default validation. Only trusted configuration +may select `ALLOW` on a configurable object; a contract-aware validator then +permits its unreviewed members. Closed objects remain closed under either policy. +Objects without the marker are closed. Generic JSON Schema validators +apply the default closure but do not implement trusted overrides. Reviewed opaque +names appear as properties classified `opaque`, so `additionalProperties` describes only unreviewed members. + +Canonical exports also declare `reject_case_aliases: true` on every reviewed +object. A member not listed in `properties` is rejected when its Unicode +`casefold()` equals the fold of a listed name, even under trusted `ALLOW`. +This includes multi-character folds (`Straße` and `STRASSE`), the Kelvin sign +(`key` and `Key`), and long-s (`refusal` and `refuſal`). Exact listed names remain +valid, whitespace remains significant, and opaque values are not traversed. +Some providers match names case-insensitively, so runtimes and generators must +enforce this explicit guardrail annotation. Generic JSON Schema ignores it. +`additionalProperties` remains the ordinary schema-level object closure rule. +Provider-required opaque fields may be left to provider validation; a projection is not a full provider request validator. Object `variant` metadata describes discriminator matching and selected-variant cardinality. This is distinct from ordinary array length constraints. diff --git a/nemoguardrails/server/experimental/contracts/guard-contract.schema.json b/nemoguardrails/server/experimental/contracts/guard-contract.schema.json index 7e507a7f78..f4fa9c67b5 100644 --- a/nemoguardrails/server/experimental/contracts/guard-contract.schema.json +++ b/nemoguardrails/server/experimental/contracts/guard-contract.schema.json @@ -354,7 +354,9 @@ "pattern": "^#/components/schemas/[A-Za-z_][A-Za-z0-9._-]*$" }, "unknown_fields": { + "description": "forbid rejects unreviewed members; configurable permits them only under a trusted ALLOW validation context. The default is FORBID.", "enum": [ + "forbid", "configurable" ] }, @@ -382,6 +384,10 @@ }, "transport": { "$ref": "#/$defs/transport" + }, + "reject_case_aliases": { + "description": "Reject any member not listed in properties whose Unicode casefold equals that of a listed property, even under trusted ALLOW. Exact names remain valid; whitespace is significant. Opaque values are not traversed.", + "const": true } } }, diff --git a/nemoguardrails/server/experimental/contracts/minimal.guard.example.yaml b/nemoguardrails/server/experimental/contracts/minimal.guard.example.yaml index a9e4409059..19023ad05a 100644 --- a/nemoguardrails/server/experimental/contracts/minimal.guard.example.yaml +++ b/nemoguardrails/server/experimental/contracts/minimal.guard.example.yaml @@ -3,6 +3,7 @@ operationId: createText profile: single_text.v1 request: type: object + additionalProperties: false required: [prompt] properties: prompt: @@ -14,10 +15,15 @@ request: kind: text role: user replaceable: true + model: + x-nemo-guardrails: + classification: opaque x-nemo-guardrails: - opaque_fields: [model] + unknown_fields: forbid + reject_case_aliases: true response: type: object + additionalProperties: false required: [text] properties: text: @@ -34,6 +40,9 @@ response: x-nemo-guardrails: classification: constrained reason: projection_policy.complete_text + x-nemo-guardrails: + unknown_fields: forbid + reject_case_aliases: true integration: endpoint: unsupported_request_code: unsupported_text_request diff --git a/nemoguardrails/server/experimental/contracts/openai/README.md b/nemoguardrails/server/experimental/contracts/openai/README.md index 615a11b20b..1f47cb7f3c 100644 --- a/nemoguardrails/server/experimental/contracts/openai/README.md +++ b/nemoguardrails/server/experimental/contracts/openai/README.md @@ -10,7 +10,7 @@ behavior. See [Guard contracts](../README.md) for the document vocabulary. | Area | Single-text boundary | | --- | --- | | Request | One user message with non-empty string content at `messages[0].content`. | -| Buffered response | One assistant choice with string content at `choices[0].message.content`. Empty or null content means the response has nothing to inspect, because every other reviewed field is content-free. The projection reports this through `has_text`; relaying such a response without output checks is left to the integration. | +| Buffered response | One assistant choice with string content at `choices[0].message.content`. Empty or null content means the response has nothing to inspect, because every other reviewed field is content-free. The projection reports this through `has_text`, and the buffered integration relays such a response without output checks. | | Constrained values | User/assistant roles, single-item arrays, and request `n` constrained to one. | | Unsupported content | Tool, audio, multimodal, refusal, participant name, and separate reasoning content where explicitly disabled. | | Opaque data | Reviewed provider-owned metadata and controls, not additional guarded subjects. | diff --git a/nemoguardrails/server/experimental/contracts/openai/_generated/chat-completions.buffered.guard.yaml b/nemoguardrails/server/experimental/contracts/openai/_generated/chat-completions.buffered.guard.yaml new file mode 100644 index 0000000000..66583de67f --- /dev/null +++ b/nemoguardrails/server/experimental/contracts/openai/_generated/chat-completions.buffered.guard.yaml @@ -0,0 +1,392 @@ +version: 1.0.0-alpha.1 +operationId: createChatCompletion +profile: single_text.v1 +request: + additionalProperties: false + description: Bind the handwritten projection to its guarded runtime semantics. + properties: + audio: + default: null + type: 'null' + x-nemo-guardrails: + classification: constrained + gate: disabled + reason: core_capability.audio_content + frequency_penalty: + x-nemo-guardrails: + classification: opaque + function_call: + default: null + type: 'null' + x-nemo-guardrails: + classification: constrained + gate: disabled + reason: core_capability.tool_content + functions: + default: null + type: 'null' + x-nemo-guardrails: + classification: constrained + gate: disabled + reason: core_capability.tool_content + logit_bias: + x-nemo-guardrails: + classification: opaque + logprobs: + default: null + x-nemo-guardrails: + classification: constrained + reason: provider_integrity.token_logprobs + oneOf: + - const: false + type: boolean + - type: 'null' + max_completion_tokens: + x-nemo-guardrails: + classification: opaque + max_tokens: + x-nemo-guardrails: + classification: opaque + messages: + items: + additionalProperties: false + description: Accept one user text message with reviewed optional provider + metadata. + properties: + content: + minLength: 1 + type: string + x-nemo-guardrails: + classification: guarded + subject: + kind: text + replaceable: true + role: user + name: + default: null + type: 'null' + x-nemo-guardrails: + classification: constrained + gate: disabled + reason: core_capability.participant_name + role: + const: user + type: string + x-nemo-guardrails: + classification: constrained + required: + - content + - role + title: ChatCompletionsUserMessageProjection + type: object + x-nemo-guardrails: + source: '#/components/schemas/ChatCompletionRequestUserMessage' + unknown_fields: configurable + reject_case_aliases: true + maxItems: 1 + minItems: 1 + type: array + x-nemo-guardrails: + classification: guarded + metadata: + x-nemo-guardrails: + classification: opaque + modalities: + default: null + type: 'null' + x-nemo-guardrails: + classification: constrained + gate: disabled + reason: core_capability.multimodal_content + model: + x-nemo-guardrails: + classification: opaque + moderation: + x-nemo-guardrails: + classification: opaque + n: + const: 1 + default: 1 + type: integer + x-nemo-guardrails: + classification: constrained + reason: core_capability.single_text_target + parallel_tool_calls: + default: null + type: 'null' + x-nemo-guardrails: + classification: constrained + gate: disabled + reason: core_capability.tool_content + prediction: + default: null + type: 'null' + x-nemo-guardrails: + classification: constrained + gate: disabled + reason: core_capability.predicted_content + presence_penalty: + 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_effort: + default: null + x-nemo-guardrails: + classification: constrained + oneOf: + - enum: + - none + - minimal + - low + - medium + - high + - xhigh + - max + type: string + - type: 'null' + response_format: + default: null + type: 'null' + x-nemo-guardrails: + classification: constrained + gate: disabled + reason: projection_policy.plain_text_output + safety_identifier: + x-nemo-guardrails: + classification: opaque + seed: + x-nemo-guardrails: + classification: opaque + service_tier: + x-nemo-guardrails: + classification: opaque + stop: + 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 + 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 + type: 'null' + x-nemo-guardrails: + classification: constrained + gate: disabled + reason: provider_integrity.token_logprobs + top_p: + x-nemo-guardrails: + classification: opaque + user: + x-nemo-guardrails: + classification: opaque + verbosity: + x-nemo-guardrails: + classification: opaque + web_search_options: + default: null + type: 'null' + x-nemo-guardrails: + classification: constrained + gate: disabled + reason: core_capability.tool_content + required: + - messages + title: openai.chat_completions.request.text.v1 + type: object + x-nemo-guardrails: + unknown_fields: forbid + reject_case_aliases: true + model: ChatCompletionsGuardedRequest + stream_selector_field: stream +response: + additionalProperties: false + description: Bind the handwritten projection to its guarded runtime semantics. + properties: + choices: + items: + additionalProperties: false + description: Expose the guarded message while preserving opaque choice metadata. + properties: + finish_reason: + default: null + x-nemo-guardrails: + classification: opaque + index: + default: null + x-nemo-guardrails: + classification: opaque + logprobs: + default: null + type: 'null' + x-nemo-guardrails: + classification: constrained + gate: disabled + reason: provider_integrity.token_logprobs + message: + additionalProperties: false + description: Accept assistant text while preventing replacement of annotated + content. + properties: + annotations: + default: null + x-nemo-guardrails: + classification: constrained + reason: core_capability.citation_content + oneOf: + - items: {} + maxItems: 0 + type: array + - type: 'null' + audio: + default: null + type: 'null' + x-nemo-guardrails: + classification: constrained + gate: disabled + reason: core_capability.audio_content + content: + x-nemo-guardrails: + classification: guarded + subject: + kind: text + replaceable: true + replacement_blocked_by: annotations + replacement_reason: provider_integrity.annotated_text + role: assistant + oneOf: + - type: string + - type: 'null' + function_call: + default: null + type: 'null' + x-nemo-guardrails: + classification: constrained + gate: disabled + reason: core_capability.tool_content + reasoning_content: + default: null + type: 'null' + x-nemo-guardrails: + classification: constrained + extension: true + gate: disabled + reason: core_capability.reasoning_content + refusal: + default: null + type: 'null' + x-nemo-guardrails: + classification: constrained + gate: disabled + reason: core_capability.refusal_content + role: + const: assistant + type: string + x-nemo-guardrails: + classification: constrained + tool_calls: + default: null + x-nemo-guardrails: + classification: constrained + reason: core_capability.tool_content + oneOf: + - items: {} + maxItems: 0 + type: array + - type: 'null' + required: + - content + - role + title: ChatCompletionsAssistantMessageProjection + type: object + x-nemo-guardrails: + classification: guarded + source: '#/components/schemas/ChatCompletionResponseMessage' + unknown_fields: configurable + reject_case_aliases: true + required: + - message + title: ChatCompletionsChoiceProjection + type: object + x-nemo-guardrails: + unknown_fields: configurable + reject_case_aliases: true + maxItems: 1 + minItems: 1 + type: array + x-nemo-guardrails: + classification: guarded + created: + x-nemo-guardrails: + classification: opaque + id: + 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 + service_tier: + x-nemo-guardrails: + classification: opaque + system_fingerprint: + x-nemo-guardrails: + classification: opaque + usage: + x-nemo-guardrails: + classification: opaque + required: + - choices + title: openai.chat_completions.response.text.v1 + type: object + x-nemo-guardrails: + unknown_fields: forbid + reject_case_aliases: true + model: ChatCompletionsGuardedResponse +integration: + name: chat_completions + endpoint: + route_path: /v1/chat/completions + operation_label: OpenAI Chat Completions + unsupported_request_code: unsupported_chat_completions_shape + unsupported_response_code: unsupported_chat_completions_response_shape diff --git a/nemoguardrails/server/experimental/provider/contract_export.py b/nemoguardrails/server/experimental/provider/contract_export.py new file mode 100644 index 0000000000..3655227cfe --- /dev/null +++ b/nemoguardrails/server/experimental/provider/contract_export.py @@ -0,0 +1,194 @@ +# 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. + +"""Export buffered guard contracts from trusted Python endpoint declarations. + +Provider integrations supply an endpoint and document identity, not exporter +implementations. Model annotations describe payload policy; endpoint metadata +describes the route and rejection codes. Exporting does not enable runtime +features or serialize arbitrary Python validation and transport behavior. + +The CLI imports one explicitly selected module attribute. That import executes +Python code: use only trusted local endpoint declarations, never references from +requests or untrusted documents. YAML and JSON Schema tooling is loaded only by +the CLI; runtime endpoint construction does not depend on this module. +""" + +from __future__ import annotations + +import argparse +import importlib +import json +import sys +from pathlib import Path +from typing import Any, Literal, get_args, get_origin + +from nemoguardrails.server.experimental.provider.endpoint import GuardedJsonEndpoint +from nemoguardrails.server.experimental.provider.payload import ( + GuardedPayloadModel, + GuardedRequestModel, + PayloadProjectionContract, + validate_payload_projection_contract, +) +from nemoguardrails.server.experimental.provider.projection_policy import ( + CONTRACT_VERSION, + EXTENSION, + PolicyModel, + export_payload_schema, + payload_contract, + text_location, +) + + +def _validate_policy_binding( + model: type[GuardedPayloadModel], direction: Literal["request", "response"] +) -> PayloadProjectionContract: + """Reject drift between supported runtime bindings and exported field policy.""" + if not issubclass(model, PolicyModel): + raise TypeError("Contract export requires policy-annotated endpoint models") + contract = validate_payload_projection_contract(model, direction) + expected = payload_contract( + model, projection_id=contract.projection_id, direction=direction, profile=contract.profile + ) + if contract != expected: + raise ValueError(f"{model.__name__}: payload coverage differs from field policy") + if model.locate_guarded_message is not GuardedPayloadModel.locate_guarded_message: + raise ValueError("Custom text extraction cannot be represented by the buffered contract") + if model.guarded_text_location != text_location(model): + raise ValueError(f"{model.__name__}: text binding differs from field policy") + return contract + + +def _validate_stream_selector(model: type[GuardedRequestModel]) -> None: + """Require a declared selector to use the standard boolean-field binding.""" + selector = model.stream_selector_field + if selector is None: + return + field = model.model_fields.get(selector) + annotation = field.annotation if field is not None else None + literal_boolean = get_origin(annotation) is Literal and all(type(value) is bool for value in get_args(annotation)) + if annotation is not bool and not literal_boolean: + raise ValueError("The stream selector must name a boolean field") + if model.streams_response is not GuardedRequestModel.streams_response: + raise ValueError("Custom response-mode selection cannot be represented by the declared stream selector") + + +def export_guard_contract(endpoint: GuardedJsonEndpoint) -> dict[str, Any]: + """Describe the buffered policy of the models actually bound to an endpoint. + + Args: + endpoint: A trusted endpoint with policy-annotated request and response + models and their runtime bindings. + + Returns: + A fresh document with payload schemas, profile, and endpoint labels. + The optional request stream selector is descriptive; there is no stream + event section. Header/query revision bindings, alternate route ownership, + and arbitrary Python behavior are not serialized. + + Raises: + TypeError: The endpoint or its models do not support policy export. + ValueError: Bound coverage, extraction, replacement, or stream selection + disagrees with the field policy, or a schema construct is unsupported. + + No files are read or written. The CLI separately validates the document + format; neither step proves upstream compatibility or runtime equivalence. + """ + if not isinstance(endpoint, GuardedJsonEndpoint): + raise TypeError("Contract export requires a GuardedJsonEndpoint") + request_model = endpoint.guarded_request_model + response_model = endpoint.guarded_response_model + if not issubclass(request_model, PolicyModel) or not issubclass(response_model, PolicyModel): + raise TypeError("Contract export requires policy-annotated endpoint models") + request_contract = _validate_policy_binding(request_model, "request") + response_contract = _validate_policy_binding(response_model, "response") + if request_contract.profile != response_contract.profile: + raise ValueError("Guarded request and response capability profiles must match") + _validate_stream_selector(request_model) + request = export_payload_schema(request_model, projection_id=request_contract.projection_id) + response = export_payload_schema(response_model, projection_id=response_contract.projection_id) + if request_model.stream_selector_field is not None: + request[EXTENSION]["stream_selector_field"] = request_model.stream_selector_field + integration: dict[str, Any] = {} + if endpoint.contract_name is not None: + integration["name"] = endpoint.contract_name + integration["endpoint"] = { + "route_path": endpoint.route_path, + "operation_label": endpoint.operation, + "unsupported_request_code": endpoint.unsupported_request_code, + "unsupported_response_code": endpoint.unsupported_response_code, + } + return { + "version": CONTRACT_VERSION, + "operationId": endpoint.provider_operation_id, + "profile": request_contract.profile.value, + "request": request, + "response": response, + "integration": integration, + } + + +def _load_endpoint(reference: str) -> GuardedJsonEndpoint: + """Import one trusted module attribute for the CLI, without discovery or eval.""" + module, separator, attribute = reference.partition(":") + if not separator or not all(part.isidentifier() for part in module.split(".")) or not attribute.isidentifier(): + raise argparse.ArgumentTypeError("Endpoint must be a Python module:attribute reference") + try: + endpoint = getattr(importlib.import_module(module), attribute) + except (ImportError, AttributeError, ValueError) as error: + raise argparse.ArgumentTypeError(f"Cannot load endpoint {reference}: {error}") from error + if not isinstance(endpoint, GuardedJsonEndpoint): + raise argparse.ArgumentTypeError(f"{reference} is not a GuardedJsonEndpoint") + return endpoint + + +def main() -> None: + """Validate and emit a buffered contract, or check an artifact for exact drift. + + The endpoint argument imports trusted Python code. With --output, create + parent directories and overwrite the selected file. With --check, leave files + untouched and exit with status 1 on missing or differing content. With neither + option, write YAML to stdout. Invalid declarations fail before file writes. + """ + parser = argparse.ArgumentParser(description="Export a buffered guard contract from a trusted Python endpoint.") + parser.add_argument("endpoint", type=_load_endpoint, help="trusted Python module:attribute (imports code)") + destination = parser.add_mutually_exclusive_group() + destination.add_argument("--output", type=Path) + destination.add_argument("--check", type=Path) + args = parser.parse_args() + + import yaml + from jsonschema import Draft202012Validator + from jsonschema.exceptions import ValidationError + + schema_path = Path(__file__).resolve().parents[1] / "contracts" / "guard-contract.schema.json" + try: + contract = export_guard_contract(args.endpoint) + Draft202012Validator(json.loads(schema_path.read_text(encoding="utf-8"))).validate(contract) + except (TypeError, ValueError, ValidationError) as error: + parser.error(str(error)) + rendered = yaml.safe_dump(contract, sort_keys=False, allow_unicode=True) + if args.check: + if not args.check.is_file() or args.check.read_text(encoding="utf-8") != rendered: + parser.exit(1, f"Export differs from {args.check}; regenerate it with --output.\n") + elif args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(rendered, encoding="utf-8") + else: + sys.stdout.write(rendered) + + +if __name__ == "__main__": + main() diff --git a/nemoguardrails/server/experimental/provider/endpoint.py b/nemoguardrails/server/experimental/provider/endpoint.py new file mode 100644 index 0000000000..2f31cda6c4 --- /dev/null +++ b/nemoguardrails/server/experimental/provider/endpoint.py @@ -0,0 +1,77 @@ +# 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 provider endpoints handled by the guarded JSON pipeline.""" + +import re +from dataclasses import dataclass + +from nemoguardrails.server.experimental._http_paths import GuardedOperationPath +from nemoguardrails.server.experimental.provider.payload import ( + GuardedPayloadModel, + GuardedRequestModel, + validate_payload_projection_contract, +) +from nemoguardrails.server.experimental.provider.transport import ProviderApiRevisionBinding + + +@dataclass(frozen=True, slots=True) +class GuardedJsonEndpoint: + """Describe one provider route and the content fields it guards.""" + + route_path: str + operation_name: str + operation: str + unsupported_request_code: str + unsupported_response_code: str + guarded_request_model: type[GuardedRequestModel] + guarded_response_model: type[GuardedPayloadModel] + provider_operation_id: str + contract_name: str | None = None + method: str = "POST" + api_revision: ProviderApiRevisionBinding | None = None + operation_paths: tuple[GuardedOperationPath, ...] = () + + def __post_init__(self) -> None: + """Validate the route, projections, and shared capability profile.""" + if not self.provider_operation_id.strip(): + raise ValueError("A provider operation ID must not be blank.") + if self.contract_name is not None and re.fullmatch(r"[a-z][a-z0-9_]*", self.contract_name) is None: + raise ValueError("A contract name must use lowercase snake case.") + if not self.operation_name or not all(part.isidentifier() for part in self.operation_name.split(".")): + raise ValueError("A guarded endpoint operation name must contain only dotted identifiers.") + if not self.operation.strip(): + raise ValueError("A guarded endpoint operation label must not be empty.") + if not self.unsupported_request_code.strip() or not self.unsupported_response_code.strip(): + raise ValueError("A guarded endpoint must declare non-empty unsupported-shape error codes.") + if not self.method or self.method != self.method.upper(): + raise ValueError("A guarded endpoint method must be a non-empty uppercase name.") + operation_paths = self.operation_paths or (GuardedOperationPath(self.route_path, frozenset({self.method})),) + if not any( + operation_path.matches(self.route_path) and self.method in operation_path.methods + for operation_path in operation_paths + ): + raise ValueError("A guarded endpoint route and method must belong to one guarded operation path.") + object.__setattr__(self, "operation_paths", operation_paths) + if not self.guarded_request_model.has_guarded_text_binding(): + raise ValueError("The guarded request projection must declare its guarded text location.") + if not self.guarded_request_model.has_response_mode_binding(): + raise ValueError("The guarded request projection must declare its provider response mode.") + if not self.guarded_response_model.has_guarded_text_binding(): + raise ValueError("The guarded response projection must declare its guarded text location.") + request_contract = validate_payload_projection_contract(self.guarded_request_model, "request") + response_contract = validate_payload_projection_contract(self.guarded_response_model, "response") + if request_contract.profile != response_contract.profile: + raise ValueError("Guarded request and response capability profiles must match.") diff --git a/nemoguardrails/server/experimental/provider/errors.py b/nemoguardrails/server/experimental/provider/errors.py new file mode 100644 index 0000000000..0c971f371a --- /dev/null +++ b/nemoguardrails/server/experimental/provider/errors.py @@ -0,0 +1,57 @@ +# 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 provider-native runtime errors to their documented response models.""" + +from dataclasses import dataclass +from typing import Any + +from pydantic import BaseModel + +from nemoguardrails.server.experimental._http_kernel import OutcomeRenderer + + +@dataclass(frozen=True, slots=True) +class ProviderErrorResponse: + """Describe one provider-native proxy error response.""" + + status_code: int + model: type[BaseModel] + description: str + + +@dataclass(frozen=True, slots=True) +class ProviderErrorMapping: + """Make one provider error authority serve runtime and OpenAPI.""" + + renderer: OutcomeRenderer + responses: tuple[ProviderErrorResponse, ...] + + def __post_init__(self) -> None: + """Require one documented model for each response status code.""" + status_codes = [response.status_code for response in self.responses] + if len(status_codes) != len(set(status_codes)): + raise ValueError("Provider error response status codes must be unique.") + + @property + def documented_responses(self) -> dict[int | str, dict[str, Any]]: + """Return FastAPI response declarations from the shared error authority.""" + return { + response.status_code: { + "model": response.model, + "description": response.description, + } + for response in self.responses + } diff --git a/nemoguardrails/server/experimental/provider/projection_policy.py b/nemoguardrails/server/experimental/provider/projection_policy.py index bade5406e4..11302db010 100644 --- a/nemoguardrails/server/experimental/provider/projection_policy.py +++ b/nemoguardrails/server/experimental/provider/projection_policy.py @@ -23,10 +23,13 @@ import re from dataclasses import dataclass -from typing import Any, ClassVar, Literal, cast, get_args +from types import UnionType +from typing import Annotated, Any, ClassVar, Literal, Union, cast, get_args, get_origin -from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, model_validator +from pydantic import BaseModel, ConfigDict, Field, GetJsonSchemaHandler, ValidationInfo, model_validator from pydantic.fields import FieldInfo +from pydantic.json_schema import JsonSchemaValue +from pydantic_core import CoreSchema from typing_extensions import Self from nemoguardrails.server.experimental.provider.payload import ( @@ -101,6 +104,8 @@ class PolicyModel(BaseModel): def __pydantic_init_subclass__(cls, **kwargs: Any) -> None: """Check local policy consistency after Pydantic has assembled fields.""" super().__pydantic_init_subclass__(**kwargs) + if cls.model_config.get("extra") != "allow": + raise ValueError(f"{cls.__name__}: ObjectPolicy requires extra='allow' to validate unreviewed members") if len(set(cls.policy.opaque)) != len(cls.policy.opaque): raise ValueError(f"{cls.__name__}: duplicate opaque fields") overlap = set(cls.policy.opaque) & cls.model_fields.keys() @@ -117,6 +122,31 @@ def __pydantic_init_subclass__(cls, **kwargs: Any) -> None: ): raise ValueError(f"{cls.__name__}.{name}: disabled fields must be optional and null-only") + @classmethod + def __get_pydantic_json_schema__(cls, core_schema: CoreSchema, handler: GetJsonSchemaHandler) -> JsonSchemaValue: + """Attach policy using model identity, independent of schema display titles. + + Keep Pydantic's references and validation assertions. Object closure + describes default validation; trusted overrides and case-alias checks + remain explicit guard annotations. + """ + node = dict(handler.resolve_ref_schema(handler(core_schema))) + if node.get("type") != "object" or "properties" not in node: + raise ValueError(f"{cls.__name__}: ObjectPolicy requires an object schema") + metadata = dict(node.get(EXTENSION, {})) + if cls.policy.source: + metadata["source"] = "#/components/schemas/" + cls.policy.source + node["additionalProperties"] = False + metadata["unknown_fields"] = cls.policy.unknown_fields + metadata["reject_case_aliases"] = True + properties = { + **node["properties"], + **{name: {EXTENSION: {"classification": "opaque"}} for name in cls.policy.opaque}, + } + node["properties"] = dict(sorted(properties.items())) + node[EXTENSION] = metadata + return node + @model_validator(mode="after") def reject_unreviewed_fields(self, info: ValidationInfo) -> Self: """Reject members outside the reviewed fields unless policy allows them. @@ -330,6 +360,49 @@ def payload_contract( ) +def policy_json_schema(model: type[PolicyModel]) -> dict[str, Any]: + """Describe default object validation without the contract's union restrictions. + + Add reviewed opaque properties, default closure, and explicit object policy + to the root and nested model definitions. Inline references so the schema can + be embedded in an OpenAPI document without dangling definition paths; retain + union semantics. Recursive models cannot be inlined and are rejected. + The result is fresh; arbitrary Python validators are not serialized. + """ + model_graph(model) + document = model.model_json_schema() + definitions = document.pop("$defs", {}) + + def inline(node: dict[str, Any], active: tuple[str, ...] = ()) -> dict[str, Any]: + """Resolve schema references without rewriting unions or opaque values.""" + if "$ref" in node: + name = node["$ref"].removeprefix("#/$defs/") + if name in active: + raise ValueError("Recursive models are not supported by the inline policy schema") + resolved = {**definitions[name], **{key: value for key, value in node.items() if key != "$ref"}} + if EXTENSION in resolved: + resolved[EXTENSION] = {**node.get(EXTENSION, {}), **definitions[name].get(EXTENSION, {})} + return inline(resolved, (*active, name)) + result = dict(node) + if "properties" in result: + metadata = dict(result.get(EXTENSION, {})) + for keyword in ("source", "unknown_fields", "reject_case_aliases"): + if keyword in metadata: + metadata[keyword] = metadata.pop(keyword) + result[EXTENSION] = metadata + result["properties"] = {name: inline(child, active) for name, child in result["properties"].items()} + if "items" in result: + result["items"] = inline(result["items"], active) + if isinstance(result.get("additionalProperties"), dict): + result["additionalProperties"] = inline(result["additionalProperties"], active) + for keyword in ("anyOf", "oneOf", "allOf", "prefixItems"): + if keyword in result: + result[keyword] = [inline(child, active) for child in result[keyword]] + return result + + return inline(document) + + def export_payload_schema(model: type[PolicyModel], *, projection_id: str) -> dict[str, Any]: """Export a fresh, inline schema with object and field guard annotations. @@ -342,7 +415,9 @@ def export_payload_schema(model: type[PolicyModel], *, projection_id: str) -> di Defaults and nullable types are retained. Disjoint nullable anyOf branches are rendered as oneOf for the current contract vocabulary. Reviewed opaque names are exported as opaque-classified properties, - and additionalProperties follows the object's unknown-field policy. + and additionalProperties reflects closed-by-default validation. Object + metadata explicitly describes trusted unknown-field overrides and Unicode + case-alias rejection. Raises: ValueError: Nested models are not policy-annotated, names collide, @@ -352,37 +427,18 @@ def export_payload_schema(model: type[PolicyModel], *, projection_id: str) -> di upstream provider schema, or capture arbitrary Python validator behavior. The operation exporter is responsible for format validation. """ - document = model.model_json_schema() - definitions = document.pop("$defs", {}) - models = model_graph(model) + document = policy_json_schema(model) - def expand(node: dict[str, Any], active: tuple[str, ...] = ()) -> dict[str, Any]: - """Inline references and attach object policy without mutating the input.""" - if "$ref" in node: - name = node["$ref"].removeprefix("#/$defs/") - if name in active: - raise ValueError("Recursive models are not supported by the contract export") - return expand({**definitions[name], **{k: v for k, v in node.items() if k != "$ref"}}, (*active, name)) + def expand(node: dict[str, Any]) -> dict[str, Any]: + """Normalize the inline schema to the narrower contract vocabulary.""" result = dict(node) metadata = dict(result.get(EXTENSION, {})) if "properties" in result: - policy = models[result["title"]].policy - if policy.source: - metadata["source"] = "#/components/schemas/" + policy.source - result["additionalProperties"] = policy.unknown_fields == "configurable" - if policy.unknown_fields == "configurable": - metadata["unknown_fields"] = policy.unknown_fields - # Opaque values stay runtime extras, but the export lists them as - # properties so additionalProperties describes only unreviewed members. - properties = { - **result["properties"], - **{name: {EXTENSION: {"classification": "opaque"}} for name in policy.opaque}, - } - result["properties"] = {name: expand(child, active) for name, child in sorted(properties.items())} + result["properties"] = {name: expand(child) for name, child in result["properties"].items()} else: result.pop("title", None) if "items" in result: - result["items"] = expand(result["items"], active) + result["items"] = expand(result["items"]) if "anyOf" in result: alternatives = result.pop("anyOf") non_null = [branch for branch in alternatives if branch != {"type": "null"}] @@ -395,7 +451,7 @@ def expand(node: dict[str, Any], active: tuple[str, ...] = ()) -> dict[str, Any] result["oneOf"] = alternatives for keyword in ("anyOf", "oneOf", "allOf"): if keyword in result: - result[keyword] = [expand(child, active) for child in result[keyword]] + result[keyword] = [expand(child) for child in result[keyword]] if metadata: result[EXTENSION] = metadata return result @@ -407,56 +463,86 @@ def expand(node: dict[str, Any], active: tuple[str, ...] = ()) -> dict[str, Any] def text_location(model: type[PolicyModel]) -> GuardedTextLocation: - """Derive one buffered text target by following guarded object fields. + """Derive one buffered text target directly from typed field declarations. - Traversed arrays must constrain their size to exactly one item. The subject - must be a named string field, optionally nullable; its role and replacement - restrictions become runtime location metadata. A nullable subject, or one - without a minimum length, may hold no text. This helper does not provide union selectors or - streaming classification and does not itself attach the result to a model. + 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. Raises: - ValueError: No unique text subject can be derived, a traversed array is - not constrained to one item, a replacement blocker is not a field of - the subject's object, or the schema cannot be exported. + ValueError: A subject is missing or ambiguous, a guarded container is + unsupported, or a replacement blocker is not a reviewed sibling. """ locations: list[GuardedTextLocation] = [] - def visit(node: dict[str, Any], path: tuple[str | int, ...]) -> None: - """Accumulate subject locations along guarded object and array paths.""" - subject = node.get(EXTENSION, {}).get("subject") - if subject: - branches = node.get("oneOf", [node]) - types = [branch.get("type") for branch in branches] - if types not in (["string"], ["string", "null"], ["null", "string"]): - raise ValueError("A guarded subject must be a named string field") - if not path or not isinstance(path[-1], str): + def unwrap(annotation: Any, metadata: list[Any]) -> tuple[Any, list[Any]]: + """Read Annotated container arguments without serializing their types.""" + if get_origin(annotation) is Annotated: + annotation, *inner = get_args(annotation) + metadata = [*inner, *metadata] + return annotation, metadata + + def bound(metadata: list[Any], name: str, default: int | None = None) -> int | None: + """Read the last declared length constraint, matching field precedence.""" + 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], ...] + ) -> None: + """Follow only explicitly guarded model and singleton-list containers.""" + annotation, metadata = unwrap(annotation, metadata) + if get_origin(annotation) is list: + 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) + elif isinstance(annotation, type) and issubclass(annotation, PolicyModel): + visit_model(annotation, path, active) + else: + raise ValueError( + "Guarded containers must lead to a named string field; alternatives need explicit bindings" + ) + + def visit_model( + current: type[PolicyModel], path: tuple[str | int, ...], active: tuple[type[PolicyModel], ...] + ) -> None: + """Collect subjects from the model objects that own their policies.""" + if current in active: + raise ValueError("Recursive guarded text paths are not supported") + reviewed = set(current.model_fields) | set(current.policy.opaque) + for name, field in current.model_fields.items(): + policy = field_policy(field) + if policy.get("classification") != "guarded": + continue + subject = policy.get("subject") + if not subject: + visit_container(field.annotation, field.metadata, (*path, name), (*active, current)) + continue + annotation, metadata = unwrap(field.annotation, field.metadata) + nullable = get_origin(annotation) in (Union, UnionType) and type(None) in get_args(annotation) + if nullable: + alternatives = [item for item in get_args(annotation) if item is not type(None)] + annotation = alternatives[0] if len(alternatives) == 1 else None + annotation, metadata = unwrap(annotation, metadata) + literal_text = get_origin(annotation) is Literal and all( + isinstance(value, str) for value in get_args(annotation) + ) + if annotation is not str and not literal_text: raise ValueError("A guarded subject must be a named string field") - text = branches[types.index("string")] + blocker = subject.get("replacement_blocked_by") + if blocker is not None and blocker not in reviewed: + raise ValueError(f"Replacement blocker {blocker!r} is not a field of the subject's object") locations.append( GuardedTextLocation( role=subject["role"], - object_path=path[:-1], - member=path[-1], + object_path=path, + member=name, allows_replacement=subject.get("replaceable", False), - replacement_blocked_by=subject.get("replacement_blocked_by"), - # A nullable subject, or one without a minimum length, may hold no text. - allows_empty="null" in types or text.get("minLength", 0) < 1, + replacement_blocked_by=blocker, + allows_empty=nullable or (bound(metadata, "min_length", 0) or 0) < 1, ) ) - if node.get("type") == "array": - if node.get("minItems") != 1 or node.get("maxItems") != 1: - raise ValueError("Buffered text extraction requires exactly one array item") - visit(node["items"], (*path, 0)) - for name, child in node.get("properties", {}).items(): - blocker = child.get(EXTENSION, {}).get("subject", {}).get("replacement_blocked_by") - # A misspelled blocker would never match and silently allow replacement. - if blocker is not None and blocker not in node["properties"]: - raise ValueError(f"Replacement blocker {blocker!r} is not a field of the subject's object") - if child.get(EXTENSION, {}).get("classification") == "guarded": - visit(child, (*path, name)) - visit(export_payload_schema(model, projection_id=model.__name__), ()) + visit_model(model, (), ()) if len(locations) != 1: raise ValueError("Buffered text extraction requires exactly one guarded subject") return locations[0] diff --git a/nemoguardrails/server/experimental/provider/transport.py b/nemoguardrails/server/experimental/provider/transport.py new file mode 100644 index 0000000000..0649bbe87f --- /dev/null +++ b/nemoguardrails/server/experimental/provider/transport.py @@ -0,0 +1,176 @@ +# 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. + +"""Describe provider fields and API revisions bound outside JSON bodies.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Sequence +from dataclasses import dataclass +from enum import Enum +from typing import Protocol +from urllib.parse import parse_qs + + +class ProviderTransportRequest(Protocol): + """Expose the HTTP metadata used by provider transport bindings.""" + + @property + def headers(self) -> Sequence[tuple[bytes, bytes]]: + """Return the request headers as raw name/value pairs.""" + + ... + + @property + def query(self) -> bytes: + """Return the raw request query string.""" + + ... + + +class TransportLocation(str, Enum): + """Identify where provider protocol data appears in an HTTP request.""" + + PATH = "path" + QUERY = "query" + HEADER = "header" + + +class ProviderBindingViolation(ValueError): + """Report request metadata outside a guarded endpoint binding.""" + + def __init__(self, message: str, *, code: str): + """Store the stable failure code for provider-native error mapping.""" + super().__init__(message) + self.code = code + + +class ApiRevisionConstraint(ABC): + """Define which client-selected API revisions a binding accepts.""" + + @abstractmethod + def accepts(self, revision: str | None) -> bool: + """Return whether a received revision is accepted.""" + + ... + + @abstractmethod + def openapi_schema(self) -> dict[str, object]: + """Describe accepted revisions as an OpenAPI string schema.""" + + ... + + @property + @abstractmethod + def example(self) -> str: + """Return one accepted revision for examples and tests.""" + + ... + + @abstractmethod + def describe(self) -> str: + """Describe accepted revisions in validation errors.""" + + ... + + +@dataclass(frozen=True, slots=True) +class ExactApiRevision(ApiRevisionConstraint): + """Accept one exact provider API revision.""" + + value: str + + def __post_init__(self) -> None: + """Require a non-empty revision.""" + + if not self.value.strip(): + raise ValueError("An exact provider API revision must not be empty.") + + def accepts(self, revision: str | None) -> bool: + """Return whether the received revision is the reviewed value.""" + + return revision == self.value + + def openapi_schema(self) -> dict[str, object]: + """Describe the exact revision as an OpenAPI string schema.""" + + return {"type": "string", "const": self.value} + + @property + def example(self) -> str: + """Return the accepted revision.""" + + return self.value + + def describe(self) -> str: + """Describe the accepted revision in validation errors.""" + + return self.value + + +@dataclass(frozen=True, slots=True) +class ProviderApiRevisionBinding: + """Bind a client-selected provider API revision to HTTP metadata.""" + + accepted: ApiRevisionConstraint + location: TransportLocation + transport_name: str + error_code: str = "unsupported_provider_api_revision" + + def __post_init__(self) -> None: + """Validate revision transport metadata.""" + + if self.location is TransportLocation.PATH: + raise ValueError("Path API versions belong to guarded endpoint routes, not revision bindings.") + if not self.transport_name.strip() or not self.error_code.strip(): + raise ValueError("Provider API revision binding values must not be empty.") + + def validate(self, request: ProviderTransportRequest) -> None: + """Validate one revision selector, rejecting even identical duplicates.""" + + if self.location is TransportLocation.HEADER: + encoded_name = self.transport_name.lower().encode("ascii") + values = [value.decode("latin-1") for name, value in request.headers if name.lower() == encoded_name] + else: + try: + query = request.query.decode("ascii") + except UnicodeDecodeError as error: + raise ProviderBindingViolation( + "The provider query must use ASCII bytes.", + code=self.error_code, + ) from error + values = parse_qs(query, keep_blank_values=True).get(self.transport_name, []) + if len(values) > 1: + raise ProviderBindingViolation( + f"The provider revision selector {self.transport_name} must not be repeated.", + code=self.error_code, + ) + received = values[0] if values else None + if not self.accepted.accepts(received): + raise ProviderBindingViolation( + f"The guarded route requires {self.transport_name}: {self.accepted.describe()}.", + code=self.error_code, + ) + + def openapi_parameter(self) -> dict[str, object]: + """Return the required OpenAPI revision parameter.""" + + return { + "name": self.transport_name, + "in": self.location.value, + "required": True, + "schema": self.accepted.openapi_schema(), + } diff --git a/nemoguardrails/server/experimental/providers/openai/chat_completions/README.md b/nemoguardrails/server/experimental/providers/openai/chat_completions/README.md index bd569b5635..40be254617 100644 --- a/nemoguardrails/server/experimental/providers/openai/chat_completions/README.md +++ b/nemoguardrails/server/experimental/providers/openai/chat_completions/README.md @@ -17,12 +17,19 @@ unknown-field policy. It is not a deployment profile selector. The capability profile remains `single_text.v1`; the exported document format remains experimental `1.0.0-alpha.1`. +`ObjectPolicy` controls object closure. `PolicyModel` requires Pydantic +`extra="allow"` internally so its validator can inspect every unreviewed member; +overriding it with `ignore` or `forbid` is rejected at class declaration. + The request/response binding modules retain their existing runtime classes. The provider revision pin lives once in [`providers/openai/source.py`](../source.py), mirroring `source.yaml`. Their coverage inventories and exact text locations are derived from the model annotations. Extraction supports one required item at each traversed array boundary; unsupported or ambiguous boundaries fail. No YAML is loaded to construct these bindings. +Text locations are derived directly from typed fields, not from exported schemas. +An unrelated constrained union therefore does not require contract-format support +just to construct a runtime binding. `export_payload_schema` exports the declared schema and policy for readers. It does not prove upstream provider compatibility or serialize arbitrary Python @@ -34,8 +41,79 @@ Existing validation behavior is preserved: omitted disabled fields default to null, explicit non-null disabled values fail, `stream` is a strict boolean, and response annotations accept only null or an empty list. `n` accepts only the integer `1`; booleans, strings, and floats are rejected. Generic JSON Schema -and Python validation are not claimed to be interchangeable. +and Python validation are not claimed to be interchangeable. Canonical exports +are closed by default and explicitly declare configurable unknown-field handling +and Unicode case-alias rejection. Contract-aware tools must implement those +annotations when trusted configuration selects `ALLOW`. + +The HTTP route publishes the same object-policy rules in its OpenAPI request +schema: reviewed opaque members remain available, and unreviewed members are +rejected by default. This schema still does not encode arbitrary Python validators. The [contract guide](../../../contracts/README.md) defines the document format; the [OpenAI boundary summary](../../../contracts/openai/README.md) records its scope and provider provenance. Neither is loaded by the models or bindings. + +## Buffered contract export + +The shared [contract exporter](../../../provider/contract_export.py) takes the +runtime endpoint, including its declared document identity. No per-operation exporter is needed. + +Before export, it checks coverage, text bindings, replacement restrictions, and any +declared stream selector against the field policy. Custom extraction and mismatched +bindings are rejected rather than described as if they were equivalent. + +```python +from nemoguardrails.server.experimental.provider.contract_export import export_guard_contract +from nemoguardrails.server.experimental.providers.openai.chat_completions.endpoint import CHAT_COMPLETIONS_ENDPOINT + +contract = export_guard_contract(CHAT_COMPLETIONS_ENDPOINT) +``` + +Run the shared CLI from the repository root: + +```bash +uv run --locked python -m nemoguardrails.server.experimental.provider.contract_export \ + nemoguardrails.server.experimental.providers.openai.chat_completions.endpoint:CHAT_COMPLETIONS_ENDPOINT \ + --output nemoguardrails/server/experimental/contracts/openai/_generated/chat-completions.buffered.guard.yaml +``` + +Use `--check` instead of `--output` with the same path to detect drift. Without +either option the document is written to stdout. The command validates against +the existing contract format before writing. The endpoint owns `provider_operation_id` and optional +`contract_name`; callers cannot override that identity. `operation_name` separately +identifies the runtime operation. + +The endpoint argument imports trusted local Python code. Do not obtain it from +requests or untrusted documents. The CLI does not scan providers or load runtime +configuration from YAML. Its YAML/JSON Schema dependencies are needed only for +serialization and format validation, not endpoint construction. + +The [exported buffered contract](../../../contracts/openai/_generated/chat-completions.buffered.guard.yaml) +gets its field policy from the Python models and endpoint labels, route, and +error codes from `CHAT_COMPLETIONS_ENDPOINT`. Do not edit the artifact by hand. +This is a buffered-only export: there is no stream section or streaming hook. +The request model recognizes the `stream` flag, but this integration still +rejects streaming requests before dispatch. Replacement eligibility is declared +by the models, while applying replacement outcomes remains unsupported by this +integration. Exporting the policy does not enable either runtime feature. + +A successful response whose assistant content is empty or null is relayed +unchanged without output checks, but only after the full response passes the +closed projection. Null content alongside refusal, reasoning, tool, or citation +content is still rejected. + +When output checks run, guarded requests ask the provider for +`accept-encoding: identity`, replacing any client value, because an encoded +response cannot be inspected. A successful response that is still encoded is +rejected, not relayed. Without output checks, the client's own preference is +forwarded and the response is relayed unchanged. + +The shared exporter describes buffered payload policy and endpoint labels, not +arbitrary transport behavior: header/query API-revision bindings and alternate +route ownership are not serialized. + +Nullable annotations are exported as disjoint array/null `oneOf` branches; the +array branch has `maxItems: 0` because citation content is not inspected. +No new contract format version, provider download, or runtime YAML loading is +introduced. diff --git a/nemoguardrails/server/experimental/providers/openai/chat_completions/endpoint.py b/nemoguardrails/server/experimental/providers/openai/chat_completions/endpoint.py new file mode 100644 index 0000000000..7cb88dd80e --- /dev/null +++ b/nemoguardrails/server/experimental/providers/openai/chat_completions/endpoint.py @@ -0,0 +1,44 @@ +# 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. + +"""Staged guarded endpoint declaration for OpenAI Chat Completions.""" + +from nemoguardrails.server.experimental._http_paths import GuardedOperationPath +from nemoguardrails.server.experimental.provider.endpoint import GuardedJsonEndpoint +from nemoguardrails.server.experimental.providers.openai.chat_completions.request_binding import ( + ChatCompletionsGuardedRequest, +) +from nemoguardrails.server.experimental.providers.openai.chat_completions.response_binding import ( + ChatCompletionsGuardedResponse, +) + +CHAT_COMPLETIONS_ENDPOINT = GuardedJsonEndpoint( + provider_operation_id="createChatCompletion", + contract_name="chat_completions", + route_path="/v1/chat/completions", + operation_name="chat_completions.create", + operation="OpenAI Chat Completions", + unsupported_request_code="unsupported_chat_completions_shape", + unsupported_response_code="unsupported_chat_completions_response_shape", + guarded_request_model=ChatCompletionsGuardedRequest, + guarded_response_model=ChatCompletionsGuardedResponse, + method="POST", + operation_paths=( + GuardedOperationPath( + "/{api_version}/chat/completions", + frozenset({"POST"}), + ), + ), +) diff --git a/nemoguardrails/server/experimental/providers/openai/errors.py b/nemoguardrails/server/experimental/providers/openai/errors.py new file mode 100644 index 0000000000..e54621eed0 --- /dev/null +++ b/nemoguardrails/server/experimental/providers/openai/errors.py @@ -0,0 +1,197 @@ +# 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. + +"""Render and document proxy failures in the OpenAI-compatible format.""" + +import json + +from pydantic import BaseModel + +from nemoguardrails.server.experimental._buffered_kernel import ( + InspectionStage, + OperationBlocked, + OperationCheckFailed, + OperationModificationUnsupported, + OperationProjectionFailed, +) +from nemoguardrails.server.experimental._guarded_operation import ( + InvalidGuardedPayload, + UnsupportedGuardedRepresentation, +) +from nemoguardrails.server.experimental._http_kernel import ( + BufferedHttpResponse, + HttpFailureKind, + HttpOperationFailed, + HttpRouteRejected, + HttpRouteRejectionKind, +) +from nemoguardrails.server.experimental.provider.errors import ProviderErrorMapping, ProviderErrorResponse + + +class OpenAIProxyErrorDetails(BaseModel): + """Describe OpenAI-compatible details generated by the proxy.""" + + message: str + type: str + param: str | None + code: str + + +class OpenAIProxyErrorResponse(BaseModel): + """Describe an OpenAI-compatible proxy error response.""" + + error: OpenAIProxyErrorDetails + + +def _error_response( + status_code: int, + message: str, + error_type: str, + code: str, + param: str | None = None, +) -> BufferedHttpResponse: + """Create a compact OpenAI-compatible JSON error response.""" + body = json.dumps( + { + "error": { + "message": message, + "type": error_type, + "param": param, + "code": code, + } + }, + separators=(",", ":"), + ).encode() + return BufferedHttpResponse(status_code, ((b"content-type", b"application/json"),), body) + + +def render_openai_error( + outcome: ( + OperationBlocked + | OperationCheckFailed + | OperationModificationUnsupported + | OperationProjectionFailed + | HttpOperationFailed + | HttpRouteRejected + ), +) -> BufferedHttpResponse: + """Render one typed proxy outcome with the OpenAI-compatible envelope.""" + + if isinstance(outcome, HttpRouteRejected): + status_code, error_type, message = { + HttpRouteRejectionKind.METHOD_NOT_ALLOWED: ( + 405, + "invalid_request_error", + "The request method is not allowed.", + ), + HttpRouteRejectionKind.NON_CANONICAL_PATH: ( + 422, + "unsupported_request", + "The guarded operation requires its canonical path.", + ), + HttpRouteRejectionKind.INVALID_REQUEST_PATH: (400, "invalid_request_error", "The request path is invalid."), + }[outcome.kind] + return _error_response(status_code, message, error_type, outcome.kind.value) + if isinstance(outcome, OperationBlocked): + return _error_response( + 400, + outcome.decision.message, + "guardrails_violation", + "content_blocked", + outcome.decision.rule, + ) + if isinstance(outcome, OperationCheckFailed): + return _error_response( + 502, + "Guardrails content inspection failed.", + "proxy_error", + f"guardrails_{outcome.stage.value}_check_failed", + ) + if isinstance(outcome, OperationModificationUnsupported): + if outcome.stage is InspectionStage.INPUT: + return _error_response( + 422, + "The guarded request content cannot be replaced.", + "unsupported_request", + "request_content_not_replaceable", + ) + return _error_response( + 502, + "The guarded provider response content cannot be replaced.", + "unsupported_response", + "response_content_not_replaceable", + ) + if isinstance(outcome, HttpOperationFailed): + status_code, error_type, message = { + HttpFailureKind.INVALID_CONTENT_LENGTH: (400, "invalid_request_error", "Invalid Content-Length."), + HttpFailureKind.REQUEST_BODY_TOO_LARGE: (413, "request_too_large", "The request body is too large."), + HttpFailureKind.UPSTREAM_REQUEST_FAILED: (502, "proxy_error", "The upstream request failed."), + HttpFailureKind.RESPONSE_BODY_TOO_LARGE: (502, "proxy_error", "The upstream response is too large."), + }[outcome.kind] + return _error_response(status_code, message, error_type, outcome.kind.value) + if outcome.stage is InspectionStage.INPUT: + if isinstance(outcome.failure, InvalidGuardedPayload): + return _error_response(400, str(outcome.failure), "invalid_request_error", "invalid_json") + if isinstance(outcome.failure, UnsupportedGuardedRepresentation): + return _error_response(415, str(outcome.failure), "unsupported_request", outcome.failure.code) + return _error_response( + 422, + "The provider request is outside the guarded profile.", + "unsupported_request", + outcome.failure.code or "unsupported_chat_completions_shape", + ) + return _error_response( + 502, + "The provider response is outside the guarded profile.", + "unsupported_response", + outcome.failure.code or "unsupported_chat_completions_response_shape", + ) + + +OPENAI_ERROR_MAPPING = ProviderErrorMapping( + renderer=render_openai_error, + responses=( + ProviderErrorResponse( + 400, + OpenAIProxyErrorResponse, + "Invalid request path or framing, malformed JSON, or content rejected by Guardrails.", + ), + ProviderErrorResponse( + 405, + OpenAIProxyErrorResponse, + "Request method rejected for a guarded or reserved operation.", + ), + ProviderErrorResponse( + 413, + OpenAIProxyErrorResponse, + "Request body rejected by the proxy size limit.", + ), + ProviderErrorResponse( + 415, + OpenAIProxyErrorResponse, + "Request representation unsupported by the guarded proxy path.", + ), + ProviderErrorResponse( + 422, + OpenAIProxyErrorResponse, + "Request path, shape, or configured Guardrails unsupported by the guarded proxy path.", + ), + ProviderErrorResponse( + 502, + OpenAIProxyErrorResponse, + "Upstream forwarding or Guardrails processing failed in the proxy.", + ), + ), +) diff --git a/nemoguardrails/server/experimental/providers/openai/integration.py b/nemoguardrails/server/experimental/providers/openai/integration.py new file mode 100644 index 0000000000..af9d2afa60 --- /dev/null +++ b/nemoguardrails/server/experimental/providers/openai/integration.py @@ -0,0 +1,54 @@ +# 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 buffered OpenAI Chat route and provider-native errors.""" + +from collections.abc import Collection, Mapping + +from fastapi import APIRouter + +from nemoguardrails.server.experimental._content_checker import ContentChecker +from nemoguardrails.server.experimental._guarded_proxy import create_buffered_guarded_http_operation +from nemoguardrails.server.experimental._http_kernel import ( + DEFAULT_MAX_REQUEST_BODY_BYTES, + DEFAULT_MAX_RESPONSE_BODY_BYTES, + HttpDispatch, + create_http_proxy_router, +) +from nemoguardrails.server.experimental.providers.openai.chat_completions.endpoint import ( + CHAT_COMPLETIONS_ENDPOINT, +) +from nemoguardrails.server.experimental.providers.openai.errors import OPENAI_ERROR_MAPPING + + +def create_openai_chat_router( + *, + checker: ContentChecker, + dispatch: HttpDispatch, + reserved_routes: Mapping[str, Collection[str]] | None = None, + max_request_body_bytes: int = DEFAULT_MAX_REQUEST_BODY_BYTES, + max_response_body_bytes: int = DEFAULT_MAX_RESPONSE_BODY_BYTES, +) -> APIRouter: + """Create the private buffered OpenAI Chat proxy surface.""" + + return create_http_proxy_router( + operations=(create_buffered_guarded_http_operation(CHAT_COMPLETIONS_ENDPOINT, OPENAI_ERROR_MAPPING),), + checker=checker, + dispatch=dispatch, + render_outcome=OPENAI_ERROR_MAPPING.renderer, + reserved_routes=reserved_routes, + max_request_body_bytes=max_request_body_bytes, + max_response_body_bytes=max_response_body_bytes, + ) diff --git a/tests/server/experimental/conftest.py b/tests/server/experimental/conftest.py index 4d01399112..16132b7bea 100644 --- a/tests/server/experimental/conftest.py +++ b/tests/server/experimental/conftest.py @@ -39,8 +39,8 @@ def _lower_export(node: object, policy: UnknownContentFieldPolicy) -> object: """Derive a JSON Schema validator input from an exported payload schema alone. Configurable objects accept unknown members only under trusted ALLOW. Any - member that matches a listed property only case-insensitively is rejected, - which the contract vocabulary does not express but its semantics require. + member that matches a listed property only after Unicode case folding is + rejected when the exported object explicitly declares that rule. """ if isinstance(node, list): return [_lower_export(child, policy) for child in node] @@ -50,7 +50,8 @@ def _lower_export(node: object, policy: UnknownContentFieldPolicy) -> object: if "properties" in node: if node.get(EXTENSION, {}).get("unknown_fields") == "configurable": lowered["additionalProperties"] = policy == UnknownContentFieldPolicy.ALLOW - lowered["x-test-reviewed-properties"] = list(node["properties"]) + 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()} return lowered diff --git a/tests/server/experimental/test_contract_export.py b/tests/server/experimental/test_contract_export.py new file mode 100644 index 0000000000..451f71e337 --- /dev/null +++ b/tests/server/experimental/test_contract_export.py @@ -0,0 +1,391 @@ +# 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 provider-neutral buffered export and its trusted local CLI.""" + +import argparse +import json +import subprocess +import sys +from dataclasses import replace +from pathlib import Path +from typing import Annotated + +import pytest +from jsonschema import Draft202012Validator + +from nemoguardrails.server.experimental.provider.contract_export import _load_endpoint, export_guard_contract +from nemoguardrails.server.experimental.provider.endpoint import GuardedJsonEndpoint +from nemoguardrails.server.experimental.provider.payload import ( + GuardedPayloadModel, + GuardedRequestModel, + GuardedTextLocation, +) +from nemoguardrails.server.experimental.provider.projection_policy import ( + EXTENSION, + PolicyModel, + constrained, + guarded, + opaque, + payload_contract, + text_location, +) + +MODULE = "nemoguardrails.server.experimental.provider.contract_export" +ROOT = Path(__file__).parents[3] + + +@pytest.fixture +def example_endpoint() -> GuardedJsonEndpoint: + """Bind a non-Chat shape with a fixed buffered response mode.""" + + class Request(PolicyModel, GuardedRequestModel): + prompt: Annotated[str, guarded("user", replaceable=True, min_length=1)] + + @property + def streams_response(self) -> bool: + """This example has no request flag and always returns buffered JSON.""" + return False + + class Response(PolicyModel, GuardedPayloadModel): + answer: Annotated[str, guarded("assistant", replaceable=False, min_length=2)] + + Request.projection_contract = payload_contract(Request, projection_id="example.request", direction="request") + Request.guarded_text_location = text_location(Request) + Response.projection_contract = payload_contract(Response, projection_id="example.response", direction="response") + Response.guarded_text_location = text_location(Response) + return GuardedJsonEndpoint( + provider_operation_id="createExampleText", + route_path="/v2/text", + operation_name="example.text", + operation="Example text", + unsupported_request_code="unsupported_example_request", + unsupported_response_code="unsupported_example_response", + guarded_request_model=Request, + guarded_response_model=Response, + ) + + +def test_export_supports_another_endpoint_without_a_stream_selector(example_endpoint): + """The exporter derives both payloads and labels from the supplied endpoint.""" + contract = export_guard_contract(example_endpoint) + schema = json.loads( + (ROOT / "nemoguardrails/server/experimental/contracts/guard-contract.schema.json").read_text(encoding="utf-8") + ) + Draft202012Validator(schema).validate(contract) + + assert contract["operationId"] == "createExampleText" + assert "name" not in contract["integration"] + assert contract["integration"]["endpoint"] == { + "route_path": "/v2/text", + "operation_label": "Example text", + "unsupported_request_code": "unsupported_example_request", + "unsupported_response_code": "unsupported_example_response", + } + assert contract["request"]["title"] == "example.request" + assert set(contract["request"]["properties"]) == {"prompt"} + assert contract["response"]["title"] == "example.response" + assert set(contract["response"]["properties"]) == {"answer"} + assert "stream_selector_field" not in contract["request"][EXTENSION] + assert "stream" not in contract + + +@pytest.mark.parametrize("direction", ["request", "response"]) +@pytest.mark.parametrize("mutation", ["replacement", "coverage", "custom_extraction"]) +def test_export_rejects_binding_policy_drift(example_endpoint, direction, mutation, monkeypatch): + """Export checks the actual binding, not only whether every field is listed.""" + model = getattr(example_endpoint, f"guarded_{direction}_model") + if mutation == "replacement": + location = model.guarded_text_location + monkeypatch.setattr( + model, "guarded_text_location", replace(location, allows_replacement=not location.allows_replacement) + ) + elif mutation == "coverage": + contract = model.projection_contract + coverage = replace(contract.root, guarded_fields=frozenset(), constrained_fields=contract.root.guarded_fields) + monkeypatch.setattr(model, "projection_contract", replace(contract, root=coverage)) + else: + + def custom_extraction(self, payload): + return self.guarded_text_location.locate(payload) + + monkeypatch.setattr(model, "locate_guarded_message", custom_extraction) + with pytest.raises(ValueError, match="differs from field policy|Custom text extraction"): + export_guard_contract(example_endpoint) + + +def test_export_rejects_a_binding_that_inspects_an_opaque_field(example_endpoint): + """A valid runtime locator cannot silently contradict the annotated subject.""" + + class Request(PolicyModel, GuardedRequestModel): + prompt: Annotated[str, guarded("user")] + audit: Annotated[str, opaque()] + + @property + def streams_response(self) -> bool: + return False + + Request.projection_contract = payload_contract(Request, projection_id="example.request", direction="request") + Request.guarded_text_location = GuardedTextLocation("user", (), "audit", allows_replacement=False) + endpoint = replace(example_endpoint, guarded_request_model=Request) + payload = {"prompt": "user content", "audit": "opaque label"} + assert Request.validate_payload(payload).locate_guarded_message(payload).message.content == "opaque label" + with pytest.raises(ValueError, match="text binding differs from field policy"): + export_guard_contract(endpoint) + + +@pytest.mark.parametrize("selector", ["missing", "prompt"]) +def test_export_rejects_non_boolean_stream_selectors(example_endpoint, selector, monkeypatch): + """A declared stream selector must name a boolean field, not arbitrary metadata.""" + monkeypatch.setattr(example_endpoint.guarded_request_model, "stream_selector_field", selector) + with pytest.raises(ValueError, match="boolean field"): + export_guard_contract(example_endpoint) + + +def test_export_rejects_custom_selection_with_a_declared_stream_selector(example_endpoint): + """An exported boolean selector must describe the runtime selection rule.""" + + class Request(PolicyModel, GuardedRequestModel): + prompt: Annotated[str, guarded("user")] + stream: Annotated[bool, constrained()] = False + + @property + def streams_response(self) -> bool: + return not self.stream + + Request.projection_contract = payload_contract(Request, projection_id="example.request", direction="request") + Request.guarded_text_location = text_location(Request) + Request.stream_selector_field = "stream" + with pytest.raises(ValueError, match="Custom response-mode selection"): + export_guard_contract(replace(example_endpoint, guarded_request_model=Request)) + + +def test_export_follows_changed_endpoint_metadata(example_endpoint): + """An endpoint variant changes the export without a provider-specific wrapper.""" + endpoint = replace( + example_endpoint, + provider_operation_id="otherOperation", + contract_name="other_text", + route_path="/v3/text", + operation_paths=(), + operation="Different text operation", + unsupported_response_code="different_response_error", + ) + contract = export_guard_contract(endpoint) + assert contract["operationId"] == "otherOperation" + assert contract["integration"]["name"] == "other_text" + assert contract["integration"]["endpoint"]["route_path"] == "/v3/text" + assert contract["integration"]["endpoint"]["operation_label"] == "Different text operation" + assert contract["integration"]["endpoint"]["unsupported_response_code"] == "different_response_error" + + +def test_http_schema_does_not_require_exportable_unions(example_endpoint): + """Policy-aware HTTP schemas preserve unions outside the contract format.""" + from nemoguardrails.server.experimental._guarded_proxy import create_buffered_guarded_http_operation + from nemoguardrails.server.experimental.provider.projection_policy import constrained + from nemoguardrails.server.experimental.providers.openai.errors import OPENAI_ERROR_MAPPING + + class UnionRequest(PolicyModel, GuardedRequestModel): + prompt: Annotated[str, guarded("user")] + option: Annotated[str | int, constrained()] + + @property + def streams_response(self) -> bool: + return False + + UnionRequest.projection_contract = payload_contract( + UnionRequest, projection_id="example.request", direction="request" + ) + UnionRequest.guarded_text_location = text_location(UnionRequest) + endpoint = replace(example_endpoint, guarded_request_model=UnionRequest) + + with pytest.raises(ValueError, match="disjoint nullable"): + export_guard_contract(endpoint) + + operation = create_buffered_guarded_http_operation(endpoint, OPENAI_ERROR_MAPPING) + schema = operation.openapi_extra["requestBody"]["content"]["application/json"]["schema"] + assert schema["additionalProperties"] is False + assert schema["properties"]["option"]["anyOf"] == [{"type": "string"}, {"type": "integer"}] + for option in ("value", 1): + payload = {"prompt": "q", "option": option} + Draft202012Validator(schema).validate(payload) + UnionRequest.validate_payload(payload) + + +def test_http_schema_supports_models_without_policy_annotations(example_endpoint): + """Handwritten bindings need not support contract export to construct a route.""" + from nemoguardrails.server.experimental._guarded_proxy import create_buffered_guarded_http_operation + from nemoguardrails.server.experimental.providers.openai.errors import OPENAI_ERROR_MAPPING + + class PlainRequest(GuardedRequestModel): + prompt: str + + @property + def streams_response(self) -> bool: + return False + + PlainRequest.projection_contract = example_endpoint.guarded_request_model.projection_contract + PlainRequest.guarded_text_location = example_endpoint.guarded_request_model.guarded_text_location + endpoint = replace(example_endpoint, guarded_request_model=PlainRequest) + + operation = create_buffered_guarded_http_operation(endpoint, OPENAI_ERROR_MAPPING) + + assert operation.openapi_extra["requestBody"]["content"]["application/json"]["schema"] == ( + PlainRequest.model_json_schema() + ) + + +@pytest.mark.parametrize("direction", ["request", "response"]) +def test_export_requires_policy_annotated_models(example_endpoint, direction): + """Valid handwritten runtime bindings alone do not promise exportable policy.""" + + class PlainRequest(GuardedRequestModel): + prompt: str + + @property + def streams_response(self) -> bool: + """Keep the example's fixed buffered response mode.""" + return False + + class PlainResponse(GuardedPayloadModel): + answer: str + + if direction == "request": + PlainRequest.projection_contract = example_endpoint.guarded_request_model.projection_contract + PlainRequest.guarded_text_location = example_endpoint.guarded_request_model.guarded_text_location + endpoint = replace(example_endpoint, guarded_request_model=PlainRequest) + else: + PlainResponse.projection_contract = example_endpoint.guarded_response_model.projection_contract + PlainResponse.guarded_text_location = example_endpoint.guarded_response_model.guarded_text_location + endpoint = replace(example_endpoint, guarded_response_model=PlainResponse) + + with pytest.raises(TypeError, match="policy-annotated"): + export_guard_contract(endpoint) + + +@pytest.mark.parametrize( + ("operation_id", "name", "message"), + [("", None, "must not be blank"), (" ", None, "must not be blank"), ("example", "Bad.Name", "snake case")], +) +def test_endpoint_rejects_invalid_document_identity(example_endpoint, operation_id, name, message): + """Reject malformed operation metadata independently of serialization.""" + with pytest.raises(ValueError, match=message): + replace(example_endpoint, provider_operation_id=operation_id, contract_name=name) + + +@pytest.mark.parametrize("reference", ["missing_separator", ":value", "pathlib:Path()", "pathlib:Path:extra"]) +def test_endpoint_reference_rejects_expressions_before_import(reference, monkeypatch): + """Only a module and one attribute are allowed; references are never evaluated.""" + + def unexpected_import(*args): + raise AssertionError("Malformed references must fail before imports") + + monkeypatch.setattr( + "nemoguardrails.server.experimental.provider.contract_export.importlib.import_module", unexpected_import + ) + with pytest.raises(argparse.ArgumentTypeError, match="module:attribute"): + _load_endpoint(reference) + + +@pytest.mark.parametrize( + ("reference", "message"), + [ + ("no_such_guard_contract_provider:endpoint", "Cannot load endpoint"), + ("pathlib:NO_SUCH_ENDPOINT", "Cannot load endpoint"), + ("pathlib:Path", "not a GuardedJsonEndpoint"), + ], +) +def test_endpoint_reference_reports_missing_or_wrong_objects(reference, message): + """The CLI requires an existing endpoint instance, not a class or factory.""" + with pytest.raises(argparse.ArgumentTypeError, match=message): + _load_endpoint(reference) + + +def test_export_module_import_does_not_load_provider_integrations(): + """Importing shared export machinery does not discover or import providers.""" + completed = subprocess.run( + [ + sys.executable, + "-c", + "import sys; import " + MODULE + "; " + "assert not any(name.startswith('nemoguardrails.server.experimental.providers.') for name in sys.modules)", + ], + capture_output=True, + text=True, + ) + assert completed.returncode == 0, completed.stderr + + +def test_export_imports_endpoint_without_http_execution_dependencies(): + source = """ +import importlib.abc +import sys + +class BlockHttpExecution(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path=None, target=None): + if fullname == "fastapi" or fullname.startswith("fastapi.") or fullname == "starlette.routing" or fullname == "nemoguardrails.server.experimental._http_kernel": + raise AssertionError(f"Export must not import {fullname}") + +sys.meta_path.insert(0, BlockHttpExecution()) +from nemoguardrails.server.experimental.provider.contract_export import export_guard_contract +from nemoguardrails.server.experimental.providers.openai.chat_completions.endpoint import CHAT_COMPLETIONS_ENDPOINT +assert export_guard_contract(CHAT_COMPLETIONS_ENDPOINT)["operationId"] == "createChatCompletion" +""" + completed = subprocess.run([sys.executable, "-c", source], capture_output=True, text=True) + assert completed.returncode == 0, completed.stderr + + +def test_export_requires_an_endpoint(): + with pytest.raises(TypeError, match="GuardedJsonEndpoint"): + export_guard_contract(object()) + + +@pytest.mark.parametrize( + ("changes", "message"), + [ + ({"operation_name": "example-text"}, "dotted identifiers"), + ({"operation": " "}, "operation label"), + ({"unsupported_request_code": " "}, "unsupported-shape error codes"), + ({"unsupported_response_code": ""}, "unsupported-shape error codes"), + ({"method": "post"}, "uppercase name"), + ({"route_path": "/v2/other"}, "belong to one guarded operation path"), + ], +) +def test_endpoint_rejects_invalid_route_declarations(example_endpoint, changes, message): + with pytest.raises(ValueError, match=message): + replace(example_endpoint, **changes) + + +def test_endpoint_requires_text_and_response_mode_bindings(example_endpoint): + """An endpoint cannot be declared without the bindings the proxy relies on.""" + request = example_endpoint.guarded_request_model + response = example_endpoint.guarded_response_model + + class UnlocatedRequest(GuardedRequestModel): + projection_contract = request.projection_contract + + class ModelessRequest(GuardedRequestModel): + projection_contract = request.projection_contract + guarded_text_location = request.guarded_text_location + + class UnlocatedResponse(GuardedPayloadModel): + projection_contract = response.projection_contract + + with pytest.raises(ValueError, match="request projection must declare its guarded text location"): + replace(example_endpoint, guarded_request_model=UnlocatedRequest) + with pytest.raises(ValueError, match="provider response mode"): + replace(example_endpoint, guarded_request_model=ModelessRequest) + with pytest.raises(ValueError, match="response projection must declare its guarded text location"): + replace(example_endpoint, guarded_response_model=UnlocatedResponse) diff --git a/tests/server/experimental/test_guard_contract.py b/tests/server/experimental/test_guard_contract.py index 4117394915..912f250e06 100644 --- a/tests/server/experimental/test_guard_contract.py +++ b/tests/server/experimental/test_guard_contract.py @@ -196,3 +196,26 @@ def test_contract_documentation_has_no_broken_local_links() -> None: broken_links.append(f"{document.name}: {link}") assert broken_links == [] + + +@pytest.mark.parametrize("unknown_fields", ["forbid", "configurable"]) +def test_contract_accepts_explicit_object_member_policy(guard_contract_schema, minimal_guard_contract, unknown_fields): + contract = deepcopy(minimal_guard_contract) + contract["request"][EXTENSION].update(unknown_fields=unknown_fields, reject_case_aliases=True) + Draft202012Validator(guard_contract_schema).validate(contract) + + +@pytest.mark.parametrize( + ("key", "value"), + [ + ("unknown_fields", "allow"), + ("unknown_fields", True), + ("reject_case_aliases", False), + ("reject_case_aliases", "true"), + ], +) +def test_contract_rejects_invalid_object_member_policy(guard_contract_schema, minimal_guard_contract, key, value): + contract = deepcopy(minimal_guard_contract) + contract["request"][EXTENSION][key] = value + with pytest.raises(ValidationError): + Draft202012Validator(guard_contract_schema).validate(contract) diff --git a/tests/server/experimental/test_http_kernel.py b/tests/server/experimental/test_http_kernel.py index 204823b257..5b1e5fc420 100644 --- a/tests/server/experimental/test_http_kernel.py +++ b/tests/server/experimental/test_http_kernel.py @@ -423,6 +423,52 @@ async def dispatch(request): assert dispatched == [] +class PolicyChecker(StaticChecker): + """Allow content under an explicit inspection policy.""" + + def __init__(self, policy): + super().__init__() + self.policy = policy + + def inspection_policy(self): + self.policy_reads += 1 + return self.policy + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("policy", "path", "expected"), + [ + (ContentInspectionPolicy(True, True), "/v1/generate", [b"identity"]), + (ContentInspectionPolicy(True, False), "/v1/generate", [b"gzip, br"]), + (ContentInspectionPolicy(False, False), "/v1/generate", [b"gzip, br"]), + (ContentInspectionPolicy(True, True), "/v1/provider-owned", [b"gzip, br"]), + ], +) +async def test_identity_encoding_is_requested_only_for_inspected_output(guarded_operation, policy, path, expected): + """Output checks need a readable body; otherwise the client's encoding preference is forwarded.""" + dispatched = [] + + async def dispatch(request): + dispatched.append(request) + return BufferedHttpResponse(200, ((b"content-type", b"application/json"),), b'{"output":"answer"}') + + app = FastAPI() + app.include_router( + create_http_proxy_router( + operations=[guarded_operation], + checker=PolicyChecker(policy), + dispatch=dispatch, + render_outcome=render_test_outcome, + ) + ) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://proxy.test") as client: + response = await client.post(path, json={"input": "question"}, headers={"accept-encoding": "gzip, br"}) + + assert response.status_code == 200 + assert [value for name, value in dispatched[0].headers if name.lower() == b"accept-encoding"] == expected + + @pytest.mark.asyncio @pytest.mark.parametrize("path", ["/v1/generate", "/v1/provider-owned"]) async def test_buffered_response_limit_hides_upstream_body(guarded_operation, path): @@ -1372,3 +1418,58 @@ async def dispatch(_request): assert response.status_code == expected_status assert response.content == b"" + + +@pytest.mark.parametrize( + "template", + [ + "/v1/text", + "/{api_version}/chat/completions", + "/v1/{model}:generateContent", + "/v1/{number:int}", + "/v1/{number:float}", + "/v1/{identifier:uuid}", + "/v1/first-{name}", + "/v1/{name:str}/tail", + "/v1/literal.{name}", + ], +) +def test_guarded_route_matching_agrees_with_starlette(template): + from starlette.routing import compile_path + + declaration = GuardedOperationPath(template) + reference, _, _ = compile_path(template) + witnesses = [ + "/v1/text", + "/v1/chat/completions", + "/v2/chat/completions", + "/v1/m:generateContent", + "/v1/12", + "/v1/1.25", + "/v1/-1", + "/v1/+1", + "/v1/12345678-1234-1234-1234-123456789abc", + "/v1/first-text", + "/v1/text/tail", + "/v1/literal.text", + "/v1/text/tail/extra", + "/v1/text\n", + ] + for witness in witnesses: + assert declaration.matches(witness) is (reference.fullmatch(witness) is not None) + + +def test_guarded_route_rejects_duplicate_parameter_names(): + with pytest.raises(ValueError, match="valid route template"): + GuardedOperationPath("/v1/{name}/{name}") + + +@pytest.mark.parametrize("adapter", ["prepare_request", "forward_request"]) +def test_guarded_operation_requires_both_request_adapters(guarded_operation, adapter): + """Preparation and forwarding adapters are declared together or not at all.""" + with pytest.raises(ValueError, match="declared together"): + GuardedHttpOperation( + operation_path=guarded_operation.operation_path, + operation=guarded_operation.operation, + **{adapter: lambda request: request}, + ) diff --git a/tests/server/experimental/test_openai_chat_contract_export.py b/tests/server/experimental/test_openai_chat_contract_export.py new file mode 100644 index 0000000000..ef440e73f3 --- /dev/null +++ b/tests/server/experimental/test_openai_chat_contract_export.py @@ -0,0 +1,191 @@ +# 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. + +"""Check that the buffered contract stays derived from the runtime endpoint.""" + +import json +import subprocess +import sys +from pathlib import Path + +import pytest +import yaml +from jsonschema import Draft202012Validator +from pydantic import ValidationError + +from nemoguardrails.server.experimental.provider import contract_export +from nemoguardrails.server.experimental.provider.contract_export import export_guard_contract +from nemoguardrails.server.experimental.provider.projection_policy import CONTRACT_VERSION, EXTENSION +from nemoguardrails.server.experimental.providers.openai.chat_completions.endpoint import CHAT_COMPLETIONS_ENDPOINT + +ROOT = Path(__file__).parents[3] +CONTRACTS = ROOT / "nemoguardrails/server/experimental/contracts" +EXPORTED = CONTRACTS / "openai/_generated/chat-completions.buffered.guard.yaml" +MODULE = "nemoguardrails.server.experimental.provider.contract_export" +ENDPOINT = "nemoguardrails.server.experimental.providers.openai.chat_completions.endpoint:CHAT_COMPLETIONS_ENDPOINT" +CLI = [sys.executable, "-m", MODULE, ENDPOINT] + + +def test_buffered_export_matches_checked_in_artifact_and_format(): + contract = export_guard_contract(CHAT_COMPLETIONS_ENDPOINT) + assert contract == yaml.safe_load(EXPORTED.read_text(encoding="utf-8")) + schema = json.loads((CONTRACTS / "guard-contract.schema.json").read_text(encoding="utf-8")) + Draft202012Validator(schema).validate(contract) + assert contract["version"] == CONTRACT_VERSION == "1.0.0-alpha.1" + assert "stream" not in contract + assert "stream_hooks" not in contract["integration"]["endpoint"] + + +def test_export_uses_the_runtime_endpoint_metadata(): + exported = export_guard_contract(CHAT_COMPLETIONS_ENDPOINT) + endpoint = CHAT_COMPLETIONS_ENDPOINT + assert exported["operationId"] == endpoint.provider_operation_id + assert exported["integration"]["name"] == endpoint.contract_name + assert exported["integration"]["endpoint"] == { + "route_path": endpoint.route_path, + "operation_label": endpoint.operation, + "unsupported_request_code": endpoint.unsupported_request_code, + "unsupported_response_code": endpoint.unsupported_response_code, + } + runtime_contract = endpoint.guarded_request_model.projection_contract + assert runtime_contract is not None + assert exported["profile"] == runtime_contract.profile.value + assert exported["request"][EXTENSION]["model"] == endpoint.guarded_request_model.__name__ + assert exported["response"][EXTENSION]["model"] == endpoint.guarded_response_model.__name__ + assert ( + exported["request"][EXTENSION]["stream_selector_field"] == endpoint.guarded_request_model.stream_selector_field + ) + + +def test_export_does_not_read_files(monkeypatch): + def unexpected_read(*args, **kwargs): + raise AssertionError("Export must derive from Python, not read YAML") + + monkeypatch.setattr(Path, "read_text", unexpected_read) + assert export_guard_contract(CHAT_COMPLETIONS_ENDPOINT)["operationId"] == "createChatCompletion" + + +def test_export_is_fresh_and_deterministic(): + first = export_guard_contract(CHAT_COMPLETIONS_ENDPOINT) + first["request"]["properties"].clear() + second = export_guard_contract(CHAT_COMPLETIONS_ENDPOINT) + assert "messages" in second["request"]["properties"] + assert second == export_guard_contract(CHAT_COMPLETIONS_ENDPOINT) + + +@pytest.mark.parametrize("annotations", [None, [], [{"type": "url_citation"}]]) +def test_export_and_runtime_accept_only_null_or_empty_annotations(annotations): + response = {"choices": [{"message": {"role": "assistant", "content": "answer", "annotations": annotations}}]} + validator = Draft202012Validator(export_guard_contract(CHAT_COMPLETIONS_ENDPOINT)["response"]) + if annotations: + assert not validator.is_valid(response) + with pytest.raises(ValidationError): + CHAT_COMPLETIONS_ENDPOINT.guarded_response_model.validate_payload(response) + return + validator.validate(response) + projection = CHAT_COMPLETIONS_ENDPOINT.guarded_response_model.validate_payload(response) + assert projection.locate_guarded_message(response).allows_replacement is True + + +def test_cli_check_and_regenerate(tmp_path): + checked = subprocess.run([*CLI, "--check", str(EXPORTED)], capture_output=True, text=True) + assert checked.returncode == 0, checked.stderr + output = tmp_path / "exported.guard.yaml" + generated = subprocess.run([*CLI, "--output", str(output)], capture_output=True, text=True) + assert generated.returncode == 0, generated.stderr + assert output.read_bytes() == EXPORTED.read_bytes() + output.write_text("changed\n", encoding="utf-8") + mismatch = subprocess.run([*CLI, "--check", str(output)], capture_output=True, text=True) + assert mismatch.returncode == 1 + assert "Export differs" in mismatch.stderr + assert output.read_text(encoding="utf-8") == "changed\n" + + +def test_cli_stdout_matches_checked_in_artifact(): + """The shared CLI preserves the deterministic default stdout mode.""" + completed = subprocess.run(CLI, capture_output=True, text=True) + assert completed.returncode == 0, completed.stderr + assert completed.stdout == EXPORTED.read_text(encoding="utf-8") + + +def test_cli_missing_check_does_not_create_an_artifact(tmp_path): + """Check mode fails without writing a missing destination.""" + missing = tmp_path / "missing" / "contract.yaml" + completed = subprocess.run([*CLI, "--check", str(missing)], capture_output=True, text=True) + assert completed.returncode == 1 + assert "Export differs" in completed.stderr + assert not missing.parent.exists() + + +@pytest.mark.parametrize("arguments", [["--operation-id", ""], ["--name", "Bad.Name"]]) +def test_cli_rejects_identity_overrides_without_overwriting_an_artifact(tmp_path, arguments): + """Identity belongs to the endpoint and cannot be overridden by CLI flags.""" + destination = tmp_path / "contract.yaml" + destination.write_text("keep\n", encoding="utf-8") + completed = subprocess.run([*CLI, *arguments, "--output", str(destination)], capture_output=True, text=True) + assert completed.returncode == 2 + assert "error:" in completed.stderr + assert destination.read_text(encoding="utf-8") == "keep\n" + + +def test_cli_rejects_wrong_endpoint_without_creating_an_artifact(tmp_path): + """A module attribute must be an endpoint instance, not arbitrary Python data.""" + destination = tmp_path / "contract.yaml" + completed = subprocess.run( + [sys.executable, "-m", MODULE, "pathlib:Path", "--output", str(destination)], + capture_output=True, + text=True, + ) + assert completed.returncode == 2 + assert "not a GuardedJsonEndpoint" in completed.stderr + assert not destination.exists() + + +def _run_cli_in_process(monkeypatch, *arguments): + """Run the CLI in this process; the subprocess tests cover the module entry point.""" + monkeypatch.setattr(sys, "argv", [MODULE, ENDPOINT, *arguments]) + contract_export.main() + + +def test_cli_modes_in_process(tmp_path, monkeypatch, capsys): + """Stdout, output, matching check, and mismatching check behave as in the subprocess.""" + _run_cli_in_process(monkeypatch) + assert capsys.readouterr().out == EXPORTED.read_text(encoding="utf-8") + + output = tmp_path / "nested" / "contract.yaml" + _run_cli_in_process(monkeypatch, "--output", str(output)) + assert output.read_bytes() == EXPORTED.read_bytes() + _run_cli_in_process(monkeypatch, "--check", str(output)) + + output.write_text("changed\n", encoding="utf-8") + with pytest.raises(SystemExit) as mismatch: + _run_cli_in_process(monkeypatch, "--check", str(output)) + assert mismatch.value.code == 1 + assert "Export differs" in capsys.readouterr().err + + +def test_cli_reports_export_failures_without_writing(tmp_path, monkeypatch, capsys): + """An endpoint whose policy cannot be exported is a usage error, and nothing is written.""" + + def reject(_endpoint): + raise ValueError("binding drift") + + monkeypatch.setattr(contract_export, "export_guard_contract", reject) + destination = tmp_path / "contract.yaml" + with pytest.raises(SystemExit) as failure: + _run_cli_in_process(monkeypatch, "--output", str(destination)) + assert failure.value.code == 2 + assert "binding drift" in capsys.readouterr().err + assert not destination.exists() diff --git a/tests/server/experimental/test_openai_chat_integration.py b/tests/server/experimental/test_openai_chat_integration.py new file mode 100644 index 0000000000..62ff16be72 --- /dev/null +++ b/tests/server/experimental/test_openai_chat_integration.py @@ -0,0 +1,779 @@ +# 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 +from dataclasses import replace + +import httpx +import pytest +import pytest_asyncio +from fastapi import FastAPI +from fastapi.routing import APIRoute +from jsonschema import Draft202012Validator +from pydantic import ValidationError + +from nemoguardrails.server.experimental._buffered_kernel import InspectionStage, OperationProjectionFailed +from nemoguardrails.server.experimental._content_checker import ( + ContentAllowed, + ContentBlocked, + ContentCheckFailed, + ContentInspectionPolicy, +) +from nemoguardrails.server.experimental._guarded_operation import UnsupportedGuardedPayload +from nemoguardrails.server.experimental._guarded_proxy import create_buffered_guarded_http_operation +from nemoguardrails.server.experimental._http_kernel import ( + BufferedHttpRequest, + BufferedHttpResponse, + HttpFailureKind, + HttpOperationFailed, + create_http_proxy_router, +) +from nemoguardrails.server.experimental.provider.errors import ProviderErrorMapping, ProviderErrorResponse +from nemoguardrails.server.experimental.provider.projection_policy import EXTENSION +from nemoguardrails.server.experimental.provider.transport import ( + ExactApiRevision, + ProviderApiRevisionBinding, + TransportLocation, +) +from nemoguardrails.server.experimental.providers.openai.chat_completions.endpoint import CHAT_COMPLETIONS_ENDPOINT +from nemoguardrails.server.experimental.providers.openai.errors import ( + OPENAI_ERROR_MAPPING, + OpenAIProxyErrorResponse, + render_openai_error, +) +from nemoguardrails.server.experimental.providers.openai.integration import create_openai_chat_router + + +class StaticChecker: + """Return configured inspection outcomes while recording calls.""" + + def __init__(self, input_decision=ContentAllowed(), output_decision=ContentAllowed()): + """Configure input and output outcomes for one test.""" + self.input_decision = input_decision + self.output_decision = output_decision + self.calls = [] + self.policy_reads = 0 + + def inspection_policy(self): + """Enable both input and output inspection.""" + self.policy_reads += 1 + return ContentInspectionPolicy(True, True) + + async def check_input(self, check): + """Record an input check and return its configured outcome.""" + self.calls.append(("input", check)) + return self.input_decision + + async def check_output(self, check): + """Record an output check and return its configured outcome.""" + self.calls.append(("output", check)) + return self.output_decision + + +def _request_body(): + """Return a representative OpenAI Chat request body.""" + return b'{ "messages" : [ { "role" : "user", "content" : "question" } ], "model" : "gpt-example", "metadata" : { "keep" : "true" } }' + + +def _response_body(): + """Return a representative OpenAI Chat response body.""" + return b'{ "id" : "chatcmpl-example", "choices" : [ { "index" : 0, "message" : { "role" : "assistant", "content" : "answer" } } ], "usage" : { "total_tokens" : 2 } }' + + +def _json_headers(*headers): + """Return JSON content headers with optional provider metadata.""" + return ((b"content-type", b"application/json"), *headers) + + +def test_openai_chat_router_constructs_its_buffered_operation(): + """The Chat router constructs its typed buffered operation at runtime.""" + + async def dispatch(_request): + raise AssertionError("construction must not dispatch a request") + + router = create_openai_chat_router(checker=StaticChecker(), dispatch=dispatch) + + assert any(isinstance(route, APIRoute) and route.path == "/v1/chat/completions" for route in router.routes) + + +def test_buffered_request_preparation_preserves_the_complete_provider_request(): + """Projection selects text without normalizing provider bytes or metadata.""" + request = BufferedHttpRequest( + method="POST", + path="/v1/chat/completions", + raw_path=b"/v1/chat/completions", + query=b"opaque=%2Fvalue&opaque=second", + headers=_json_headers((b"x-provider-opaque", b"first"), (b"x-provider-opaque", b"second")), + body=_request_body(), + ) + operation = create_buffered_guarded_http_operation(CHAT_COMPLETIONS_ENDPOINT, OPENAI_ERROR_MAPPING) + + prepared = operation.prepare_request(request) + + assert operation.operation.input_projection(prepared).content == "question" + assert operation.forward_request(prepared) is request + + +@pytest_asyncio.fixture +async def proxy_harness(): + """Provide a proxy client, checker, and dispatched-request log.""" + checker = StaticChecker() + dispatched = [] + + async def dispatch(request): + """Return deterministic Chat or catch-all provider responses.""" + dispatched.append(request) + if request.path == "/v1/chat/completions": + return BufferedHttpResponse( + 200, + ((b"content-type", b"application/json"), (b"x-request-id", b"provider-chat-id")), + _response_body(), + ) + return BufferedHttpResponse( + 200, + ((b"content-type", b"application/json"), (b"x-request-id", b"provider-models-id")), + b'{"object":"list","data":[]}', + ) + + app = FastAPI() + app.include_router(create_openai_chat_router(checker=checker, dispatch=dispatch)) + client = httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://proxy.test") + try: + yield client, checker, dispatched + finally: + await client.aclose() + + +@pytest.mark.asyncio +async def test_openai_chat_passes_checks_without_rewriting_provider_http(proxy_harness): + """Guarded Chat passes both checks without rewriting provider HTTP data.""" + client, checker, dispatched = proxy_harness + request_body = _request_body() + + response = await client.post( + "/v1/chat/completions?provider_option=opaque", + content=request_body, + headers={"content-type": "application/json", "x-provider-option": "preserve"}, + ) + + assert response.status_code == 200 + assert response.content == _response_body() + assert response.headers["x-request-id"] == "provider-chat-id" + assert len(dispatched) == 1 + assert dispatched[0].body == request_body + assert dispatched[0].query == b"provider_option=opaque" + assert (b"x-provider-option", b"preserve") in dispatched[0].headers + assert [call[0] for call in checker.calls] == ["input", "output"] + assert checker.calls[0][1].message.content == "question" + assert checker.calls[1][1].input_message.content == "question" + assert checker.calls[1][1].output_content == "answer" + + +@pytest.mark.asyncio +async def test_openai_catchall_forwards_without_checking_content(proxy_harness): + """Unbound provider routes bypass content inspection and preserve HTTP data.""" + client, checker, dispatched = proxy_harness + + response = await client.get("/v1/models?limit=10", headers={"x-provider-option": "preserve"}) + + assert response.status_code == 200 + assert response.content == b'{"object":"list","data":[]}' + assert response.headers["x-request-id"] == "provider-models-id" + assert checker.calls == [] + assert dispatched[0].path == "/v1/models" + assert dispatched[0].query == b"limit=10" + + +@pytest.mark.asyncio +async def test_future_chat_api_version_cannot_bypass_guarded_projection(proxy_harness): + """A future Chat API route cannot escape the guarded operation boundary.""" + client, checker, dispatched = proxy_harness + + response = await client.post( + "/v2/chat/completions", + content=_request_body(), + headers={"content-type": "application/json"}, + ) + + assert response.status_code == 422 + assert checker.calls == [] + assert dispatched == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("decision", "expected_type", "expected_code"), + [ + (ContentBlocked("Request blocked.", "policy_rule"), "guardrails_violation", "content_blocked"), + (ContentCheckFailed("internal checker detail"), "proxy_error", "guardrails_input_check_failed"), + ], +) +async def test_input_guard_outcome_is_openai_shaped_and_prevents_dispatch(decision, expected_type, expected_code): + """Stopped input produces an OpenAI error without provider dispatch.""" + checker = StaticChecker(input_decision=decision) + dispatched = [] + + async def dispatch(request): + """Fail if a stopped input reaches provider dispatch.""" + dispatched.append(request) + raise AssertionError("a stopped input must not be dispatched") + + app = FastAPI() + app.include_router(create_openai_chat_router(checker=checker, dispatch=dispatch)) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://proxy.test") as client: + response = await client.post( + "/v1/chat/completions", + content=_request_body(), + headers={"content-type": "application/json"}, + ) + + assert response.status_code == (400 if isinstance(decision, ContentBlocked) else 502) + assert response.json()["error"]["type"] == expected_type + assert response.json()["error"]["code"] == expected_code + assert response.json()["error"]["param"] == ("policy_rule" if isinstance(decision, ContentBlocked) else None) + assert "internal checker detail" not in response.text + assert dispatched == [] + + +@pytest.mark.asyncio +async def test_output_block_hides_provider_response_in_openai_error(): + """Blocked output hides the provider body and headers behind an OpenAI error.""" + checker = StaticChecker(output_decision=ContentBlocked("Response blocked.")) + provider_body = _response_body() + + async def dispatch(_request): + """Return a provider response containing data that must not escape.""" + return BufferedHttpResponse(200, _json_headers((b"x-provider-secret", b"hidden")), provider_body) + + app = FastAPI() + app.include_router(create_openai_chat_router(checker=checker, dispatch=dispatch)) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://proxy.test") as client: + response = await client.post( + "/v1/chat/completions", + content=_request_body(), + headers={"content-type": "application/json"}, + ) + + assert response.status_code == 400 + assert response.json()["error"]["code"] == "content_blocked" + assert provider_body not in response.content + assert "x-provider-secret" not in response.headers + + +@pytest.mark.asyncio +async def test_guarded_request_asks_the_provider_for_identity_encoding(proxy_harness): + """A client that accepts gzip still gets a response the proxy can inspect.""" + client, checker, dispatched = proxy_harness + + response = await client.post( + "/v1/chat/completions", + content=_request_body(), + headers={"content-type": "application/json", "accept-encoding": "gzip, deflate, br"}, + ) + + assert response.status_code == 200 + assert [value for key, value in dispatched[0].headers if key.lower() == b"accept-encoding"] == [b"identity"] + assert [stage for stage, _check in checker.calls] == ["input", "output"] + + +@pytest.mark.asyncio +async def test_encoded_successful_response_is_not_inspected_or_relayed(): + """A provider that encodes anyway is rejected rather than relayed unchecked.""" + checker = StaticChecker() + + async def dispatch(_request): + """Return a gzip-labelled response despite the identity request.""" + return BufferedHttpResponse(200, _json_headers((b"content-encoding", b"gzip")), b"\x1f\x8b") + + app = FastAPI() + app.include_router(create_openai_chat_router(checker=checker, dispatch=dispatch)) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://proxy.test") as client: + response = await client.post( + "/v1/chat/completions", content=_request_body(), headers={"content-type": "application/json"} + ) + + assert response.status_code == 502 + assert response.json()["error"]["code"] == "unsupported_chat_completions_response_shape" + assert [stage for stage, _check in checker.calls] == ["input"] + + +async def _relay_through_proxy(message, finish_reason="stop"): + """Send one provider response message through the proxy and record its checks.""" + checker = StaticChecker(output_decision=ContentBlocked("Response blocked.")) + choice = {"index": 0, "finish_reason": finish_reason} + if message is not None: + choice["message"] = message + provider_body = json.dumps({"id": "chatcmpl-example", "choices": [choice]}, separators=(",", ":")).encode() + + async def dispatch(_request): + """Return the provider response under test.""" + return BufferedHttpResponse(200, _json_headers(), provider_body) + + app = FastAPI() + app.include_router(create_openai_chat_router(checker=checker, dispatch=dispatch)) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://proxy.test") as client: + response = await client.post( + "/v1/chat/completions", content=_request_body(), headers={"content-type": "application/json"} + ) + return response, provider_body, [stage for stage, _check in checker.calls] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("content", "finish_reason"), + [("", "length"), (None, "content_filter"), (None, "stop")], +) +async def test_response_without_text_is_relayed_without_output_check(content, finish_reason): + """A validated response with no text skips output rails and is relayed unchanged.""" + response, provider_body, stages = await _relay_through_proxy( + {"role": "assistant", "content": content}, finish_reason + ) + + assert response.status_code == 200 + assert response.content == provider_body + assert stages == ["input"] + + +@pytest.mark.asyncio +async def test_response_with_text_is_still_output_checked(): + """Ordinary assistant text still goes through output rails.""" + response, provider_body, stages = await _relay_through_proxy({"role": "assistant", "content": "answer"}) + + assert response.status_code == 400 + assert provider_body not in response.content + assert stages == ["input", "output"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "message", + [ + None, + {"role": "assistant"}, + {"role": "assistant", "content": None, "refusal": "untrusted model text"}, + {"role": "assistant", "content": None, "reasoning_content": "untrusted model text"}, + {"role": "assistant", "content": None, "tool_calls": [{"id": "call", "type": "function"}]}, + {"role": "assistant", "content": None, "annotations": [{"type": "url_citation"}]}, + ], +) +async def test_response_without_text_cannot_skip_shape_validation(message): + """Missing text never excuses content in another field or a missing message.""" + response, provider_body, stages = await _relay_through_proxy(message) + + assert response.status_code == 502 + assert response.json()["error"]["code"] == "unsupported_chat_completions_response_shape" + assert provider_body not in response.content + assert stages == ["input"] + + +@pytest.mark.asyncio +async def test_unsupported_request_is_openai_shaped_and_not_dispatched(): + """Unsupported Chat shapes produce an OpenAI error before dispatch.""" + dispatched = [] + + async def dispatch(request): + """Fail if an unsupported request reaches provider dispatch.""" + dispatched.append(request) + raise AssertionError("an unsupported request must not be dispatched") + + app = FastAPI() + app.include_router(create_openai_chat_router(checker=StaticChecker(), dispatch=dispatch)) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://proxy.test") as client: + response = await client.post( + "/v1/chat/completions", + json={"messages": [{"role": "user", "content": "question"}], "stream": True}, + ) + + assert response.status_code == 422 + assert response.json()["error"]["type"] == "unsupported_request" + assert response.json()["error"]["code"] == "unsupported_chat_completions_shape" + assert dispatched == [] + + +@pytest.mark.asyncio +async def test_unsupported_success_response_is_hidden_in_openai_error(): + """Unsupported successful responses are hidden behind an OpenAI error.""" + provider_body = b'{"choices":[{"message":{"role":"assistant","content":"answer","tool_calls":[{"id":"call"}]}}]}' + + async def dispatch(_request): + """Return an unsupported provider response with private metadata.""" + return BufferedHttpResponse(200, _json_headers((b"x-provider-secret", b"hidden")), provider_body) + + app = FastAPI() + app.include_router(create_openai_chat_router(checker=StaticChecker(), dispatch=dispatch)) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://proxy.test") as client: + response = await client.post( + "/v1/chat/completions", + content=_request_body(), + headers={"content-type": "application/json"}, + ) + + assert response.status_code == 502 + assert response.json()["error"]["code"] == "unsupported_chat_completions_response_shape" + assert provider_body not in response.content + assert "x-provider-secret" not in response.headers + + +@pytest.mark.asyncio +async def test_provider_error_response_is_preserved_without_output_check(): + """Provider error responses pass through without output inspection.""" + checker = StaticChecker() + provider_body = b'{ "error" : { "message" : "rate limited", "type" : "provider_error" } }' + + async def dispatch(_request): + """Return a provider-native error response.""" + return BufferedHttpResponse( + 429, + ((b"content-type", b"application/json"), (b"x-request-id", b"provider-error-id")), + provider_body, + ) + + app = FastAPI() + app.include_router(create_openai_chat_router(checker=checker, dispatch=dispatch)) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://proxy.test") as client: + response = await client.post( + "/v1/chat/completions", + content=_request_body(), + headers={"content-type": "application/json"}, + ) + + assert response.status_code == 429 + assert response.content == provider_body + assert response.headers["x-request-id"] == "provider-error-id" + assert [call[0] for call in checker.calls] == ["input"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("checker", "status_code", "code", "dispatches"), + [ + (StaticChecker(input_decision=ContentAllowed("replacement")), 422, "request_content_not_replaceable", 0), + (StaticChecker(output_decision=ContentAllowed("replacement")), 502, "response_content_not_replaceable", 1), + ], +) +async def test_replacement_remains_explicitly_unsupported(checker, status_code, code, dispatches): + """Replacement outcomes stay unsupported until buffered replacement lands.""" + dispatched = [] + + async def dispatch(request): + """Record dispatch and return a successful provider response.""" + dispatched.append(request) + return BufferedHttpResponse(200, _json_headers(), _response_body()) + + app = FastAPI() + app.include_router(create_openai_chat_router(checker=checker, dispatch=dispatch)) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://proxy.test") as client: + response = await client.post( + "/v1/chat/completions", + content=_request_body(), + headers={"content-type": "application/json"}, + ) + + assert response.status_code == status_code + assert response.json()["error"]["code"] == code + assert len(dispatched) == dispatches + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("content", "headers", "status_code", "code"), + [ + (b"not json", {"content-type": "application/json"}, 400, "invalid_json"), + (_request_body(), {}, 415, "unsupported_media_type"), + ( + _request_body(), + {"content-type": "application/json", "content-encoding": "gzip"}, + 415, + "unsupported_content_encoding", + ), + ], +) +async def test_request_representation_failures_are_native_and_stop_before_dispatch( + content, + headers, + status_code, + code, +): + """Invalid request representations produce native errors before dispatch.""" + dispatched = [] + + async def dispatch(request): + """Fail if an invalid representation reaches provider dispatch.""" + dispatched.append(request) + raise AssertionError("an invalid guarded representation must not be dispatched") + + app = FastAPI() + app.include_router(create_openai_chat_router(checker=StaticChecker(), dispatch=dispatch)) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://proxy.test") as client: + response = await client.post("/v1/chat/completions", content=content, headers=headers) + + assert response.status_code == status_code + assert response.json()["error"]["code"] == code + assert dispatched == [] + + +def test_openai_error_preserves_provider_binding_failure_code(): + """Provider binding failures retain their stable code in OpenAI errors.""" + outcome = OperationProjectionFailed( + InspectionStage.INPUT, + UnsupportedGuardedPayload("Unsupported provider API revision.", "unsupported_provider_api_revision"), + ) + + response = render_openai_error(outcome) + + assert response.status_code == 422 + assert b'"code":"unsupported_provider_api_revision"' in response.body + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("stage", "body", "headers", "status", "code"), + [ + ("request", b'{"messages":[]}', _json_headers(), 422, "example_request_shape"), + ("request", b'["private-payload"]', _json_headers(), 422, "example_request_shape"), + ("request", b'{"messages":[],"stream":true}', _json_headers(), 422, "example_request_shape"), + ("request", b'{"private-payload":', _json_headers(), 400, "invalid_json"), + ("request", _request_body(), ((b"content-type", b"text/plain"),), 415, "unsupported_media_type"), + ("response", b'{"choices":[]}', _json_headers(), 502, "example_response_shape"), + ("response", b'["private-payload"]', _json_headers(), 502, "example_response_shape"), + ("response", b'{"private-payload":', _json_headers(), 502, "example_response_shape"), + ("response", b"private-payload", ((b"content-type", b"text/plain"),), 502, "example_response_shape"), + ( + "response", + b"private-payload", + _json_headers((b"content-encoding", b"gzip")), + 502, + "example_response_shape", + ), + ], +) +async def test_endpoint_error_codes_control_guarded_http_failures(stage, body, headers, status, code): + """Endpoint-specific shape codes survive the shared pipeline and safe renderer.""" + endpoint = replace( + CHAT_COMPLETIONS_ENDPOINT, + unsupported_request_code="example_request_shape", + unsupported_response_code="example_response_shape", + ) + checker = StaticChecker() + dispatched = [] + + async def dispatch(request): + dispatched.append(request) + assert stage == "response" + return BufferedHttpResponse(200, headers, body) + + app = FastAPI() + app.include_router( + create_http_proxy_router( + operations=(create_buffered_guarded_http_operation(endpoint, OPENAI_ERROR_MAPPING),), + checker=checker, + 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( + endpoint.route_path, + content=body if stage == "request" else _request_body(), + headers=headers if stage == "request" else _json_headers(), + ) + assert response.status_code == status + assert response.json()["error"]["code"] == code + assert "private-payload" not in response.text + assert len(dispatched) == (stage == "response") + assert [call[0] for call in checker.calls] == (["input"] if stage == "response" else []) + + +@pytest.mark.parametrize( + ("location", "member", "accepted"), + [ + ("root", "model", True), + ("root", "metadata", True), + ("root", "future_field", False), + ("root", "Model", False), + ("message", "future_field", False), + ("message", "Content", False), + ], +) +def test_openai_request_openapi_matches_default_object_policy(location, member, accepted): + """Published request schemas retain opaque members and close unreviewed ones.""" + app = FastAPI() + + async def dispatch(_request): + raise AssertionError("OpenAPI generation must not dispatch") + + app.include_router(create_openai_chat_router(checker=StaticChecker(), dispatch=dispatch)) + document = app.openapi() + schema = document["paths"]["/v1/chat/completions"]["post"]["requestBody"]["content"]["application/json"]["schema"] + message_schema = schema["properties"]["messages"]["items"] + assert schema["additionalProperties"] is False + assert schema[EXTENSION]["unknown_fields"] == "forbid" + assert message_schema["additionalProperties"] is False + assert message_schema[EXTENSION]["unknown_fields"] == "configurable" + assert message_schema[EXTENSION]["reject_case_aliases"] is True + assert schema["properties"]["model"][EXTENSION]["classification"] == "opaque" + + payload = json.loads(_request_body()) + target = payload if location == "root" else payload["messages"][0] + target[member] = {"arbitrary": [1, "opaque"]} if member == "metadata" else "value" + assert Draft202012Validator(document).evolve(schema=schema).is_valid(payload) is accepted + if accepted: + CHAT_COMPLETIONS_ENDPOINT.guarded_request_model.validate_payload(payload) + else: + with pytest.raises(ValidationError): + CHAT_COMPLETIONS_ENDPOINT.guarded_request_model.validate_payload(payload) + + +def test_openai_error_mapping_is_the_openapi_response_authority(): + """The runtime error mapping also supplies the OpenAPI response models.""" + app = FastAPI() + + async def dispatch(_request): + """Fail if OpenAPI generation attempts provider dispatch.""" + raise AssertionError + + app.include_router(create_openai_chat_router(checker=StaticChecker(), dispatch=dispatch)) + operation = app.openapi()["paths"]["/v1/chat/completions"]["post"] + + assert operation["requestBody"]["required"] is True + assert operation["requestBody"]["content"]["application/json"]["schema"]["title"] == ( + "ChatCompletionsGuardedRequest" + ) + assert set(operation["responses"]) == { + "200", + *(str(response.status_code) for response in OPENAI_ERROR_MAPPING.responses), + } + for response in OPENAI_ERROR_MAPPING.responses: + documented = operation["responses"][str(response.status_code)] + assert documented["description"] == response.description + assert documented["content"]["application/json"]["schema"]["$ref"].endswith( + f"/{OpenAIProxyErrorResponse.__name__}" + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("prefix", ["", "/proxy"]) +@pytest.mark.parametrize( + ("method", "path", "status_code", "code", "allowed"), + [ + ("GET", "/v1/chat/completions", 405, "method_not_allowed", "POST"), + ("POST", "/v1//chat/completions", 422, "non_canonical_path", None), + ("POST", "/v1/%2e/chat/completions", 422, "non_canonical_path", None), + ("GET", "/v1%5Cmodels", 400, "invalid_request_path", None), + ("POST", "/health", 405, "method_not_allowed", "GET"), + ], +) +async def test_route_rejections_use_native_errors_before_checking_or_dispatch( + prefix, method, path, status_code, code, allowed +): + """Render native route errors while keeping method metadata and stopping dispatch.""" + + checker = StaticChecker() + + async def dispatch(_request): + pytest.fail("a rejected route must not reach provider dispatch") + + app = FastAPI() + app.include_router( + create_openai_chat_router(checker=checker, dispatch=dispatch, reserved_routes={"/health": {"GET"}}), + prefix=prefix, + ) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://proxy.test") as client: + response = await client.request( + method, prefix + path, content=_request_body(), headers={"content-type": "application/json"} + ) + + assert response.status_code == status_code + assert response.json()["error"]["code"] == code + assert response.headers.get("allow") == allowed + assert checker.calls == [] + assert status_code in {response.status_code for response in OPENAI_ERROR_MAPPING.responses} + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("status_code", "body"), [(200, b"{}"), (204, b"")]) +async def test_success_without_guarded_chat_content_fails_provider_projection(status_code, body): + """Keep successful-response applicability fail-closed in the Chat binding.""" + + checker = StaticChecker() + + async def dispatch(_request): + return BufferedHttpResponse(status_code, _json_headers((b"x-provider-secret", b"hidden")), body) + + app = FastAPI() + app.include_router(create_openai_chat_router(checker=checker, dispatch=dispatch)) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://proxy.test") as client: + response = await client.post( + "/v1/chat/completions", content=_request_body(), headers={"content-type": "application/json"} + ) + + assert response.status_code == 502 + assert response.json()["error"]["code"] == "unsupported_chat_completions_response_shape" + assert "x-provider-secret" not in response.headers + assert [stage for stage, _check in checker.calls] == ["input"] + + +def _revision_bound_operation(): + """Bind the Chat endpoint to a required provider API revision header.""" + revision = ProviderApiRevisionBinding( + accepted=ExactApiRevision("2026-09-25"), + location=TransportLocation.HEADER, + transport_name="provider-version", + ) + endpoint = replace(CHAT_COMPLETIONS_ENDPOINT, api_revision=revision) + return create_buffered_guarded_http_operation(endpoint, OPENAI_ERROR_MAPPING) + + +def _chat_request(*headers): + return BufferedHttpRequest( + "POST", "/v1/chat/completions", b"/v1/chat/completions", b"", _json_headers(*headers), _request_body() + ) + + +def test_guarded_operation_requires_and_documents_the_bound_api_revision(): + """A revision binding is checked before guarded parsing and appears in the OpenAPI parameters.""" + operation = _revision_bound_operation() + + assert [parameter["name"] for parameter in operation.openapi_extra["parameters"]] == ["provider-version"] + assert operation.prepare_request(_chat_request((b"provider-version", b"2026-09-25"))).target.has_text + with pytest.raises(UnsupportedGuardedPayload) as rejected: + operation.prepare_request(_chat_request((b"provider-version", b"2025-01-01"))) + assert rejected.value.code == "unsupported_provider_api_revision" + + +def test_provider_error_mapping_requires_unique_status_codes(): + response = ProviderErrorResponse(400, OpenAIProxyErrorResponse, "Bad request") + with pytest.raises(ValueError, match="unique"): + ProviderErrorMapping(render_openai_error, (response, response)) + + +@pytest.mark.parametrize( + ("kind", "status_code", "error_type"), + [ + (HttpFailureKind.INVALID_CONTENT_LENGTH, 400, "invalid_request_error"), + (HttpFailureKind.REQUEST_BODY_TOO_LARGE, 413, "request_too_large"), + (HttpFailureKind.UPSTREAM_REQUEST_FAILED, 502, "proxy_error"), + (HttpFailureKind.RESPONSE_BODY_TOO_LARGE, 502, "proxy_error"), + ], +) +def test_http_failures_render_as_openai_errors(kind, status_code, error_type): + """Transport failures render as OpenAI errors without the underlying exception text.""" + response = render_openai_error(HttpOperationFailed(kind, RuntimeError("internal detail"))) + + body = json.loads(response.body) + assert response.status_code == status_code + assert body["error"]["type"] == error_type + assert body["error"]["code"] == kind.value + assert "internal detail" not in response.body.decode() diff --git a/tests/server/experimental/test_openai_chat_projections.py b/tests/server/experimental/test_openai_chat_projections.py index 1b0788ebf4..5a34903e8a 100644 --- a/tests/server/experimental/test_openai_chat_projections.py +++ b/tests/server/experimental/test_openai_chat_projections.py @@ -542,11 +542,13 @@ def test_export_follows_each_object_unknown_field_policy(): choice = response["properties"]["choices"]["items"] configurable = (request["properties"]["messages"]["items"], choice, choice["properties"]["message"]) for content in configurable: - assert content["additionalProperties"] is True + assert content["additionalProperties"] is False assert content[EXTENSION]["unknown_fields"] == "configurable" for root in (request, response): assert root["additionalProperties"] is False - assert "unknown_fields" not in root[EXTENSION] + assert root[EXTENSION]["unknown_fields"] == "forbid" + for node in (*configurable, request, response): + assert node[EXTENSION]["reject_case_aliases"] is True def test_field_validation_matches_original_unannotated_declarations(): diff --git a/tests/server/experimental/test_projection_policy.py b/tests/server/experimental/test_projection_policy.py index 9e8078a38c..d471ebd679 100644 --- a/tests/server/experimental/test_projection_policy.py +++ b/tests/server/experimental/test_projection_policy.py @@ -17,7 +17,7 @@ from typing import Annotated, ClassVar, Literal import pytest -from pydantic import Field, ValidationError +from pydantic import ConfigDict, Field, ValidationError from nemoguardrails.server.experimental.provider.payload import GuardedContentModel from nemoguardrails.server.experimental.provider.projection_policy import ( @@ -29,6 +29,7 @@ export_payload_schema, guarded, payload_contract, + policy_json_schema, text_location, ) from nemoguardrails.server.experimental.provider.types import UnknownContentFieldPolicy @@ -54,6 +55,55 @@ class Request(PolicyModel): assert message["properties"]["tools"]["default"] is None +@pytest.mark.parametrize("extra", ["ignore", "forbid", None]) +def test_policy_models_reject_conflicting_extra_configuration(extra): + """Object policy must see all extras before deciding whether to accept them.""" + with pytest.raises(ValueError, match="requires extra='allow'"): + + class Invalid(PolicyModel): + model_config = ConfigDict(extra=extra) + policy: ClassVar[ObjectPolicy] = ObjectPolicy(opaque=("provider_id",)) + text: Annotated[str, guarded("user")] + + +@pytest.mark.parametrize("export", [False, True]) +def test_object_policy_uses_model_identity_not_schema_titles(export, export_policy_validator): + """Display titles and field titles cannot hide or swap object policies.""" + + class Left(PolicyModel): + model_config = ConfigDict(title="Shared display title") + policy: ClassVar[ObjectPolicy] = ObjectPolicy(opaque=("left_metadata",)) + text: Annotated[str, guarded("user")] + + class Right(PolicyModel): + model_config = ConfigDict(title="Shared display title") + policy: ClassVar[ObjectPolicy] = ObjectPolicy(opaque=("right_metadata",)) + value: Annotated[str, constrained()] + + class Root(PolicyModel): + model_config = ConfigDict(title="Readable request") + left: Annotated[Left, guarded(), Field(title="Friendly field")] + right: Annotated[Right, constrained()] + + schema = export_payload_schema(Root, projection_id="test.request") if export else policy_json_schema(Root) + assert schema["additionalProperties"] is False + assert schema[EXTENSION]["unknown_fields"] == "forbid" + for field, expected in (("left", "left_metadata"), ("right", "right_metadata")): + node = schema["properties"][field] + assert node["additionalProperties"] is False + assert node[EXTENSION]["reject_case_aliases"] is True + assert expected in node["properties"] + assert "right_metadata" not in schema["properties"]["left"]["properties"] + payload = {"left": {"text": "q", "left_metadata": 1}, "right": {"value": "v", "right_metadata": 2}} + validator = export_policy_validator(schema, UnknownContentFieldPolicy.FORBID) + assert validator.is_valid(payload) + Root.model_validate(payload) + payload["left"]["unreviewed"] = "x" + assert not validator.is_valid(payload) + with pytest.raises(ValidationError): + Root.model_validate(payload) + + def test_missing_policy_fails_at_class_definition(): with pytest.raises(ValueError, match="missing field policy"): @@ -158,6 +208,28 @@ def test_replacement_policy_requires_subject(): guarded(replaceable=True) +def test_text_binding_derivation_does_not_generate_schemas(monkeypatch): + """Unrelated unions and schema-export availability cannot change a text path.""" + import nemoguardrails.server.experimental.provider.projection_policy as policies + + class Request(PolicyModel): + text: Annotated[str, guarded("user", min_length=2)] + option: Annotated[str | int, constrained()] + + def unexpected_schema(*args, **kwargs): + raise AssertionError("Runtime binding derivation must not generate schemas") + + monkeypatch.setattr(policies, "export_payload_schema", unexpected_schema) + monkeypatch.setattr(policies, "policy_json_schema", unexpected_schema) + monkeypatch.setattr(Request, "model_json_schema", unexpected_schema) + + location = text_location(Request) + assert location.object_path == () + assert location.member == "text" + assert location.role == "user" + assert location.allows_empty is False + + def test_extraction_rejects_non_singleton_arrays(): class Message(PolicyModel): text: Annotated[str, guarded("user")] @@ -169,6 +241,17 @@ class Request(PolicyModel): text_location(Request) +def test_extraction_rejects_recursive_guarded_paths(): + """Recursive guarded containers fail explicitly rather than recursing forever.""" + + class Node(PolicyModel): + children: Annotated["list[Node]", guarded(min_length=1, max_length=1)] + + Node.model_rebuild() + with pytest.raises(ValueError, match="Recursive guarded text paths"): + text_location(Node) + + def test_extraction_rejects_unknown_replacement_blocker(): class Message(PolicyModel): text: Annotated[str, guarded("assistant", replaceable=True, blocked_by="annotatons")] @@ -326,3 +409,44 @@ def test_extraction_requires_a_named_string_subject(annotation): with pytest.raises(ValueError, match="named string field"): text_location(root) + + +@pytest.mark.parametrize("policy", list(UnknownContentFieldPolicy)) +@pytest.mark.parametrize("member", ["key", "Straße", "Key", "STRASSE", "Content", "future", "Content\n"]) +def test_explicit_export_member_rules_match_runtime(member, policy, export_policy_validator): + from nemoguardrails.server.experimental.provider.payload import GuardedContentModel, GuardedPayloadModel + + class Message(PolicyModel, GuardedContentModel): + policy: ClassVar[ObjectPolicy] = ObjectPolicy(opaque=("key", "Straße"), unknown_fields="configurable") + content: Annotated[str, guarded("user", min_length=1)] + + class Request(PolicyModel, GuardedPayloadModel): + message: Annotated[Message, guarded()] + + document = {"message": {"content": "q", member: "opaque"}} + exported = export_payload_schema(Request, projection_id="example.request") + expected = member in {"key", "Straße"} or ( + policy == UnknownContentFieldPolicy.ALLOW and member in {"future", "Content\n"} + ) + assert export_policy_validator(exported, policy).is_valid(document) is expected + if expected: + Request.validate_payload(document, unknown_content_fields=policy) + else: + with pytest.raises(ValidationError): + Request.validate_payload(document, unknown_content_fields=policy) + if policy == UnknownContentFieldPolicy.ALLOW and member in {"Key", "STRASSE", "Content"}: + del exported["properties"]["message"][EXTENSION]["reject_case_aliases"] + assert export_policy_validator(exported, policy).is_valid(document) + + +def test_export_inlines_policy_models_used_as_mapping_values(): + class Part(PolicyModel): + text: Annotated[str, guarded("user")] + + class Root(PolicyModel): + parts: Annotated[dict[str, Part], constrained()] + + values = export_payload_schema(Root, projection_id="test")["properties"]["parts"]["additionalProperties"] + assert "$ref" not in values + assert values["properties"]["text"][EXTENSION]["classification"] == "guarded" + assert values[EXTENSION]["unknown_fields"] == "forbid" diff --git a/tests/server/experimental/test_provider_revision_binding.py b/tests/server/experimental/test_provider_revision_binding.py new file mode 100644 index 0000000000..0047db0967 --- /dev/null +++ b/tests/server/experimental/test_provider_revision_binding.py @@ -0,0 +1,146 @@ +# 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 dataclasses import dataclass + +import pytest + +from nemoguardrails.server.experimental.provider.transport import ( + ExactApiRevision, + ProviderApiRevisionBinding, + ProviderBindingViolation, + TransportLocation, +) + + +@dataclass(frozen=True) +class RequestMetadata: + """Provide the request metadata consumed by revision bindings.""" + + headers: tuple[tuple[bytes, bytes], ...] = () + query: bytes = b"" + + +@pytest.mark.parametrize( + ("location", "transport_name", "metadata"), + [ + ( + TransportLocation.HEADER, + "provider-version", + RequestMetadata(headers=((b"provider-version", b"2026-09-25"),)), + ), + ( + TransportLocation.QUERY, + "api-version", + RequestMetadata(query=b"api-version=2026-09-25"), + ), + ], +) +def test_provider_api_revision_binding_accepts_exact_header_or_query_revision(location, transport_name, metadata): + """An exact provider revision may be bound to a header or query value.""" + binding = ProviderApiRevisionBinding( + accepted=ExactApiRevision("2026-09-25"), + location=location, + transport_name=transport_name, + ) + + binding.validate(metadata) + + +@pytest.mark.parametrize("location", [TransportLocation.HEADER, TransportLocation.QUERY]) +@pytest.mark.parametrize( + "revisions", + [("unreviewed", "2026-09-25"), ("2026-09-25", "unreviewed"), ("2026-09-25", "2026-09-25")], +) +def test_provider_api_revision_binding_rejects_duplicate_selectors(location, revisions): + """Reject ambiguous revision transport before an upstream can interpret it.""" + binding = ProviderApiRevisionBinding( + accepted=ExactApiRevision("2026-09-25"), + location=location, + transport_name="api-version", + error_code="ambiguous_test_revision", + ) + if location is TransportLocation.HEADER: + metadata = RequestMetadata( + headers=((b"api-version", revisions[0].encode()), (b"API-Version", revisions[1].encode())) + ) + else: + metadata = RequestMetadata(query=f"api-version={revisions[0]}&api-version={revisions[1]}".encode()) + + with pytest.raises(ProviderBindingViolation, match="must not be repeated") as exc_info: + binding.validate(metadata) + + assert exc_info.value.code == "ambiguous_test_revision" + + +def test_provider_api_revision_binding_rejects_missing_revision_with_stable_code(): + """A missing required revision reports a stable provider error code.""" + binding = ProviderApiRevisionBinding( + accepted=ExactApiRevision("2026-09-25"), + location=TransportLocation.HEADER, + transport_name="provider-version", + ) + + with pytest.raises(ProviderBindingViolation) as exc_info: + binding.validate(RequestMetadata()) + + assert exc_info.value.code == "unsupported_provider_api_revision" + assert binding.openapi_parameter() == { + "name": "provider-version", + "in": "header", + "required": True, + "schema": {"type": "string", "const": "2026-09-25"}, + } + + +def test_provider_api_revision_binding_rejects_non_ascii_query_with_stable_code(): + """Malformed query bytes produce the binding's provider failure outcome.""" + binding = ProviderApiRevisionBinding( + accepted=ExactApiRevision("2026-09-25"), + location=TransportLocation.QUERY, + transport_name="api-version", + ) + + with pytest.raises(ProviderBindingViolation) as exc_info: + binding.validate(RequestMetadata(query=b"api-version=2026-09-25\xff")) + + assert exc_info.value.code == "unsupported_provider_api_revision" + + +def test_provider_api_revision_binding_rejects_path_location(): + """Path revisions belong to route ownership rather than metadata bindings.""" + with pytest.raises(ValueError, match="belong to guarded endpoint routes"): + ProviderApiRevisionBinding( + accepted=ExactApiRevision("v1"), + location=TransportLocation.PATH, + transport_name="api_version", + ) + + +def test_exact_revision_requires_a_value_and_offers_it_as_the_example(): + with pytest.raises(ValueError, match="must not be empty"): + ExactApiRevision(" ") + assert ExactApiRevision("2026-09-25").example == "2026-09-25" + + +@pytest.mark.parametrize(("transport_name", "error_code"), [(" ", "unsupported_version"), ("api-version", " ")]) +def test_provider_api_revision_binding_requires_names(transport_name, error_code): + with pytest.raises(ValueError, match="must not be empty"): + ProviderApiRevisionBinding( + accepted=ExactApiRevision("2026-09-25"), + location=TransportLocation.HEADER, + transport_name=transport_name, + error_code=error_code, + ) diff --git a/tests/test_http_error_handling.py b/tests/test_http_error_handling.py index 10a22c1626..965ae0199e 100644 --- a/tests/test_http_error_handling.py +++ b/tests/test_http_error_handling.py @@ -23,6 +23,7 @@ from fastapi.testclient import TestClient +from nemoguardrails.context import llm_call_info_var from nemoguardrails.exceptions import ( InvalidStateError, LLMAuthenticationError, @@ -105,6 +106,13 @@ class _FakeLLMModel: class TestRaiseLLMCallException: + @pytest.fixture(autouse=True) + def _isolate_llm_call_info(self): + """Read model context from the fake model, not call info left by another test.""" + token = llm_call_info_var.set(None) + yield + llm_call_info_var.reset(token) + def test_propagates_status_from_inner(self): with pytest.raises(LLMCallException) as exc_info: _raise_llm_call_exception(LLMAuthenticationError(401, "Bad key"), _FakeLLMModel())