From 5d6f6b7735f2382b4b017ab88abc84d9de423d5d Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:00:39 +0200 Subject: [PATCH 01/27] feat(server): integrate OpenAI Chat buffered proxy Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- .../server/experimental/_guarded_operation.py | 19 + .../server/experimental/_guarded_proxy.py | 197 +++++++++ .../server/experimental/_http_kernel.py | 49 ++- .../server/experimental/provider/endpoint.py | 70 ++++ .../server/experimental/provider/errors.py | 57 +++ .../server/experimental/provider/transport.py | 165 ++++++++ .../openai/chat_completions/endpoint.py | 42 ++ .../experimental/providers/openai/errors.py | 174 ++++++++ .../providers/openai/integration.py | 54 +++ .../test_openai_chat_integration.py | 392 ++++++++++++++++++ .../test_provider_revision_binding.py | 89 ++++ 11 files changed, 1301 insertions(+), 7 deletions(-) create mode 100644 nemoguardrails/server/experimental/_guarded_proxy.py create mode 100644 nemoguardrails/server/experimental/provider/endpoint.py create mode 100644 nemoguardrails/server/experimental/provider/errors.py create mode 100644 nemoguardrails/server/experimental/provider/transport.py create mode 100644 nemoguardrails/server/experimental/providers/openai/chat_completions/endpoint.py create mode 100644 nemoguardrails/server/experimental/providers/openai/errors.py create mode 100644 nemoguardrails/server/experimental/providers/openai/integration.py create mode 100644 tests/server/experimental/test_openai_chat_integration.py create mode 100644 tests/server/experimental/test_provider_revision_binding.py 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..94c0f0d264 --- /dev/null +++ b/nemoguardrails/server/experimental/_guarded_proxy.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. + +"""Compose provider declarations with the guarded buffered HTTP pipeline.""" + +from dataclasses import dataclass, replace + +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.transport import ProviderBindingViolation +from nemoguardrails.server.experimental.provider.types import GuardedMessage, JsonObject + + +@dataclass(slots=True) +class GuardableProviderRequest: + """Hold one original HTTP request and its validated exact guarded target.""" + + request: BufferedHttpRequest + raw_body: bytes + payload: JsonObject + target: GuardedMessageTarget + streaming: bool + + +@dataclass(frozen=True, slots=True) +class PreparedProviderRequest: + """Hold the exact provider request state after input inspection.""" + + body: bytes + input_message: GuardedMessage + body_modified: bool + streaming: bool + + +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)) 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")) from error + if projection.streams_response: + raise UnsupportedGuardedPayload("The guarded endpoint does not support streaming responses yet.") + return GuardableProviderRequest( + request=request, + raw_body=request.body, + payload=payload, + target=target, + streaming=False, + ) + + +def _prepare_provider_request(request: GuardableProviderRequest) -> PreparedProviderRequest: + """Preserve the original provider body after input inspection.""" + return PreparedProviderRequest( + body=request.raw_body, + input_message=request.target.message, + body_modified=False, + streaming=request.streaming, + ) + + +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: + """Return the provider request that should be dispatched.""" + prepared = _prepare_provider_request(request) + return replace(request.request, body=prepared.body) + + 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.") + 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.") + try: + document = parse_json_object(payload.body) + projection = endpoint.guarded_response_model.validate_payload(document) + return projection.locate_guarded_message(document).message + except ValidationError as error: + raise UnsupportedGuardedPayload(guarded_schema_error(error, "successful response")) from error + except (InvalidJson, UnsupportedJsonShape) as error: + raise UnsupportedGuardedPayload(str(error)) from error + + openapi_extra = { + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": endpoint.guarded_request_model.model_json_schema(), + } + }, + } + } + if endpoint.api_revision is not None: + openapi_extra["parameters"] = [endpoint.api_revision.openapi_parameter()] + + return GuardedHttpOperation( + operation_path=GuardedOperationPath(endpoint.route_path, frozenset({endpoint.method})), + operation=BufferedGuardedOperation[GuardableProviderRequest, BufferedHttpResponse]( + name=endpoint.operation_name, + input_projection=project_request, + output_projection=project_response, + ), + 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..df3c565767 100644 --- a/nemoguardrails/server/experimental/_http_kernel.py +++ b/nemoguardrails/server/experimental/_http_kernel.py @@ -19,6 +19,7 @@ from collections.abc import Awaitable, Callable, Collection, Mapping from dataclasses import dataclass from enum import Enum +from typing import Any, cast from urllib.parse import quote from fastapi import APIRouter, Request @@ -27,6 +28,7 @@ from starlette.routing import compile_path from nemoguardrails.server.experimental._buffered_kernel import ( + InspectionStage, OperationBlocked, OperationCheckFailed, OperationCompleted, @@ -39,7 +41,7 @@ _ResolvedContentChecker, validate_content_checker, ) -from nemoguardrails.server.experimental._guarded_operation import BufferedGuardedOperation +from nemoguardrails.server.experimental._guarded_operation import BufferedGuardedOperation, UnsupportedGuardedPayload HTTP_METHODS = ("DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT") DEFAULT_MAX_REQUEST_BODY_BYTES = 1024 * 1024 @@ -177,7 +179,20 @@ 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 +266,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) @@ -440,10 +457,15 @@ 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) + ) + response = await dispatch(forwarded) if len(response.body) > max_response_body_bytes: raise ResponseBodyTooLarge return response @@ -457,10 +479,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 +556,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 +571,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/provider/endpoint.py b/nemoguardrails/server/experimental/provider/endpoint.py new file mode 100644 index 0000000000..7759bb1ec4 --- /dev/null +++ b/nemoguardrails/server/experimental/provider/endpoint.py @@ -0,0 +1,70 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Declare provider endpoints handled by the guarded JSON pipeline.""" + +from dataclasses import dataclass + +from nemoguardrails.server.experimental._http_kernel 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] + 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.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/transport.py b/nemoguardrails/server/experimental/provider/transport.py new file mode 100644 index 0000000000..744cc96755 --- /dev/null +++ b/nemoguardrails/server/experimental/provider/transport.py @@ -0,0 +1,165 @@ +# 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 a client-selected header or query revision.""" + + 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] + received = values[-1] if values else None + else: + values = parse_qs(request.query.decode("ascii"), keep_blank_values=True).get(self.transport_name, []) + received = values[-1] 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/endpoint.py b/nemoguardrails/server/experimental/providers/openai/chat_completions/endpoint.py new file mode 100644 index 0000000000..399804f8db --- /dev/null +++ b/nemoguardrails/server/experimental/providers/openai/chat_completions/endpoint.py @@ -0,0 +1,42 @@ +# 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_kernel 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( + 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..6a368a6f50 --- /dev/null +++ b/nemoguardrails/server/experimental/providers/openai/errors.py @@ -0,0 +1,174 @@ +# 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, +) +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 + ), +) -> BufferedHttpResponse: + """Render one typed proxy outcome with the OpenAI-compatible envelope.""" + + 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 OpenAI Chat Completions request is outside the guarded profile.", + "unsupported_request", + "unsupported_chat_completions_shape", + ) + return _error_response( + 502, + "The OpenAI Chat Completions response is outside the guarded profile.", + "unsupported_response", + "unsupported_chat_completions_response_shape", + ) + + +OPENAI_ERROR_MAPPING = ProviderErrorMapping( + renderer=render_openai_error, + responses=( + ProviderErrorResponse( + 400, + OpenAIProxyErrorResponse, + "Invalid request framing, malformed JSON, or content rejected by Guardrails.", + ), + 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 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/test_openai_chat_integration.py b/tests/server/experimental/test_openai_chat_integration.py new file mode 100644 index 0000000000..19345111a8 --- /dev/null +++ b/tests/server/experimental/test_openai_chat_integration.py @@ -0,0 +1,392 @@ +# 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 httpx +import pytest +import pytest_asyncio +from fastapi import FastAPI + +from nemoguardrails.server.experimental._content_checker import ( + ContentAllowed, + ContentBlocked, + ContentCheckFailed, + ContentInspectionPolicy, +) +from nemoguardrails.server.experimental._http_kernel import BufferedHttpResponse +from nemoguardrails.server.experimental.providers.openai.errors import ( + OPENAI_ERROR_MAPPING, + OpenAIProxyErrorResponse, +) +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", "opaque" : { "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" } } ], "opaque" : [ 1, 2 ] }' + + +def _json_headers(*headers): + """Return JSON content headers with optional provider metadata.""" + return ((b"content-type", b"application/json"), *headers) + + +@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_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":[]}}]}' + + 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_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__}" + ) 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..d5148c3eb5 --- /dev/null +++ b/tests/server/experimental/test_provider_revision_binding.py @@ -0,0 +1,89 @@ +# 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) + + +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_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", + ) From b577e1dd85db6943d0b32a0515f7a2366ef4d7b1 Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Sat, 26 Sep 2026 22:29:03 +0200 Subject: [PATCH 02/27] fix(server): construct buffered operation on Python 3.10 Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- nemoguardrails/server/experimental/_guarded_proxy.py | 12 +++++++----- .../experimental/test_openai_chat_integration.py | 12 ++++++++++++ 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/nemoguardrails/server/experimental/_guarded_proxy.py b/nemoguardrails/server/experimental/_guarded_proxy.py index 94c0f0d264..658d4e46b6 100644 --- a/nemoguardrails/server/experimental/_guarded_proxy.py +++ b/nemoguardrails/server/experimental/_guarded_proxy.py @@ -182,13 +182,15 @@ def project_response( 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=BufferedGuardedOperation[GuardableProviderRequest, BufferedHttpResponse]( - name=endpoint.operation_name, - input_projection=project_request, - output_projection=project_response, - ), + operation=operation, prepare_request=prepare_request, forward_request=forward_request, documented_responses=errors.documented_responses, diff --git a/tests/server/experimental/test_openai_chat_integration.py b/tests/server/experimental/test_openai_chat_integration.py index 19345111a8..d5d2fc6492 100644 --- a/tests/server/experimental/test_openai_chat_integration.py +++ b/tests/server/experimental/test_openai_chat_integration.py @@ -17,6 +17,7 @@ import pytest import pytest_asyncio from fastapi import FastAPI +from fastapi.routing import APIRoute from nemoguardrails.server.experimental._content_checker import ( ContentAllowed, @@ -73,6 +74,17 @@ def _json_headers(*headers): 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) + + @pytest_asyncio.fixture async def proxy_harness(): """Provide a proxy client, checker, and dispatched-request log.""" From 4ea116dd816517ac83ff32c7994a2d37a86b3457 Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Sat, 26 Sep 2026 23:10:16 +0200 Subject: [PATCH 03/27] fix(server): preserve provider binding failures Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- .../server/experimental/provider/transport.py | 9 ++++++++- .../experimental/providers/openai/errors.py | 2 +- .../experimental/test_openai_chat_integration.py | 16 ++++++++++++++++ .../test_provider_revision_binding.py | 14 ++++++++++++++ 4 files changed, 39 insertions(+), 2 deletions(-) diff --git a/nemoguardrails/server/experimental/provider/transport.py b/nemoguardrails/server/experimental/provider/transport.py index 744cc96755..e8c1395964 100644 --- a/nemoguardrails/server/experimental/provider/transport.py +++ b/nemoguardrails/server/experimental/provider/transport.py @@ -146,7 +146,14 @@ def validate(self, request: ProviderTransportRequest) -> None: values = [value.decode("latin-1") for name, value in request.headers if name.lower() == encoded_name] received = values[-1] if values else None else: - values = parse_qs(request.query.decode("ascii"), keep_blank_values=True).get(self.transport_name, []) + 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, []) received = values[-1] if values else None if not self.accepted.accepts(received): raise ProviderBindingViolation( diff --git a/nemoguardrails/server/experimental/providers/openai/errors.py b/nemoguardrails/server/experimental/providers/openai/errors.py index 6a368a6f50..ca7017da29 100644 --- a/nemoguardrails/server/experimental/providers/openai/errors.py +++ b/nemoguardrails/server/experimental/providers/openai/errors.py @@ -132,7 +132,7 @@ def render_openai_error( 422, "The OpenAI Chat Completions request is outside the guarded profile.", "unsupported_request", - "unsupported_chat_completions_shape", + outcome.failure.code or "unsupported_chat_completions_shape", ) return _error_response( 502, diff --git a/tests/server/experimental/test_openai_chat_integration.py b/tests/server/experimental/test_openai_chat_integration.py index d5d2fc6492..662c8986ca 100644 --- a/tests/server/experimental/test_openai_chat_integration.py +++ b/tests/server/experimental/test_openai_chat_integration.py @@ -19,16 +19,19 @@ from fastapi import FastAPI from fastapi.routing import APIRoute +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._http_kernel import BufferedHttpResponse 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 @@ -377,6 +380,19 @@ async def dispatch(request): 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 + + def test_openai_error_mapping_is_the_openapi_response_authority(): """The runtime error mapping also supplies the OpenAPI response models.""" app = FastAPI() diff --git a/tests/server/experimental/test_provider_revision_binding.py b/tests/server/experimental/test_provider_revision_binding.py index d5148c3eb5..1f7ae70794 100644 --- a/tests/server/experimental/test_provider_revision_binding.py +++ b/tests/server/experimental/test_provider_revision_binding.py @@ -79,6 +79,20 @@ def test_provider_api_revision_binding_rejects_missing_revision_with_stable_code } +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"): From 1863bbd74ebd8399d265fe7728a31f86fc53b095 Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:10:37 +0200 Subject: [PATCH 04/27] fix(server): render native OpenAI proxy route rejections Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- .../experimental/providers/openai/errors.py | 27 +++++++- .../test_openai_chat_integration.py | 62 +++++++++++++++++++ 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/nemoguardrails/server/experimental/providers/openai/errors.py b/nemoguardrails/server/experimental/providers/openai/errors.py index ca7017da29..3b483f4d30 100644 --- a/nemoguardrails/server/experimental/providers/openai/errors.py +++ b/nemoguardrails/server/experimental/providers/openai/errors.py @@ -34,6 +34,8 @@ BufferedHttpResponse, HttpFailureKind, HttpOperationFailed, + HttpRouteRejected, + HttpRouteRejectionKind, ) from nemoguardrails.server.experimental.provider.errors import ProviderErrorMapping, ProviderErrorResponse @@ -82,10 +84,26 @@ def render_openai_error( | 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, @@ -148,7 +166,12 @@ def render_openai_error( ProviderErrorResponse( 400, OpenAIProxyErrorResponse, - "Invalid request framing, malformed JSON, or content rejected by Guardrails.", + "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, @@ -163,7 +186,7 @@ def render_openai_error( ProviderErrorResponse( 422, OpenAIProxyErrorResponse, - "Request shape or configured Guardrails unsupported by the guarded proxy path.", + "Request path, shape, or configured Guardrails unsupported by the guarded proxy path.", ), ProviderErrorResponse( 502, diff --git a/tests/server/experimental/test_openai_chat_integration.py b/tests/server/experimental/test_openai_chat_integration.py index 662c8986ca..636017ffd7 100644 --- a/tests/server/experimental/test_openai_chat_integration.py +++ b/tests/server/experimental/test_openai_chat_integration.py @@ -418,3 +418,65 @@ async def dispatch(_request): 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"] From 54d0936c15f9c5f759dc717be78f94362f16726b Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:41:30 +0200 Subject: [PATCH 05/27] feat(server): export the handwritten buffered Chat contract Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- .../chat-completions.buffered.guard.yaml | 285 ++++++++++++++++++ .../openai/chat_completions/README.md | 26 ++ .../openai/chat_completions/contract.py | 91 ++++++ .../test_openai_chat_contract_export.py | 99 ++++++ 4 files changed, 501 insertions(+) create mode 100644 nemoguardrails/server/experimental/contracts/openai/_generated/chat-completions.buffered.guard.yaml create mode 100644 nemoguardrails/server/experimental/providers/openai/chat_completions/contract.py create mode 100644 tests/server/experimental/test_openai_chat_contract_export.py 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..f6da310f24 --- /dev/null +++ b/nemoguardrails/server/experimental/contracts/openai/_generated/chat-completions.buffered.guard.yaml @@ -0,0 +1,285 @@ +version: 1.0.0-alpha.1 +operationId: createChatCompletion +profile: single_text.v1 +request: + additionalProperties: true + 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 + 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 + messages: + items: + additionalProperties: true + properties: + content: + minLength: 1 + type: string + x-nemo-guardrails: + classification: guarded + subject: + kind: text + replaceable: true + role: user + name: + default: null + x-nemo-guardrails: + classification: opaque + 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 + maxItems: 1 + minItems: 1 + type: array + x-nemo-guardrails: + classification: guarded + modalities: + default: null + type: 'null' + x-nemo-guardrails: + classification: constrained + gate: disabled + reason: core_capability.multimodal_content + 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 + response_format: + default: null + type: 'null' + x-nemo-guardrails: + classification: constrained + gate: disabled + reason: projection_policy.plain_text_output + stream: + default: false + type: boolean + x-nemo-guardrails: + classification: constrained + 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 + 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: + opaque_fields: + - frequency_penalty + - logit_bias + - logprobs + - max_completion_tokens + - max_tokens + - metadata + - model + - moderation + - presence_penalty + - prompt_cache_key + - prompt_cache_options + - prompt_cache_retention + - reasoning_effort + - safety_identifier + - seed + - service_tier + - stop + - store + - stream_options + - temperature + - top_logprobs + - top_p + - user + - verbosity + model: ChatCompletionsGuardedRequest + stream_selector_field: stream +response: + additionalProperties: true + description: Bind the handwritten projection to its guarded runtime semantics. + properties: + choices: + items: + additionalProperties: true + 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: true + properties: + annotations: + default: null + x-nemo-guardrails: + classification: constrained + oneOf: + - items: {} + type: array + - type: 'null' + audio: + default: null + type: 'null' + x-nemo-guardrails: + classification: constrained + gate: disabled + reason: core_capability.audio_content + content: + minLength: 1 + type: string + x-nemo-guardrails: + classification: guarded + subject: + kind: text + replaceable: true + replacement_blocked_by: annotations + replacement_reason: provider_integrity.annotated_text + role: assistant + 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 + type: 'null' + x-nemo-guardrails: + classification: constrained + gate: disabled + reason: core_capability.tool_content + required: + - content + - role + title: ChatCompletionsAssistantMessageProjection + type: object + x-nemo-guardrails: + classification: guarded + source: '#/components/schemas/ChatCompletionResponseMessage' + unknown_fields: configurable + required: + - message + title: ChatCompletionsChoiceProjection + type: object + x-nemo-guardrails: + unknown_fields: configurable + maxItems: 1 + minItems: 1 + type: array + x-nemo-guardrails: + classification: guarded + required: + - choices + title: openai.chat_completions.response.text.v1 + type: object + x-nemo-guardrails: + opaque_fields: + - created + - id + - metadata + - model + - moderation + - object + - service_tier + - system_fingerprint + - usage + 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/providers/openai/chat_completions/README.md b/nemoguardrails/server/experimental/providers/openai/chat_completions/README.md index bd569b5635..4f1cd8a2e0 100644 --- a/nemoguardrails/server/experimental/providers/openai/chat_completions/README.md +++ b/nemoguardrails/server/experimental/providers/openai/chat_completions/README.md @@ -39,3 +39,29 @@ and Python validation are not claimed to be interchangeable. 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 + +Run the exporter from the repository root: + +```bash +poetry run python -m nemoguardrails.server.experimental.providers.openai.chat_completions.contract \ + --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 [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. + +Nullable annotations are exported as disjoint array/null `oneOf` branches. +No new contract format version, provider download, or runtime YAML loading is +introduced. diff --git a/nemoguardrails/server/experimental/providers/openai/chat_completions/contract.py b/nemoguardrails/server/experimental/providers/openai/chat_completions/contract.py new file mode 100644 index 0000000000..28804fba99 --- /dev/null +++ b/nemoguardrails/server/experimental/providers/openai/chat_completions/contract.py @@ -0,0 +1,91 @@ +# 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 __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + +from nemoguardrails.server.experimental.provider.payload import validate_payload_projection_contract +from nemoguardrails.server.experimental.provider.projection_policy import ( + CONTRACT_VERSION, + EXTENSION, + PolicyModel, + export_payload_schema, +) +from nemoguardrails.server.experimental.providers.openai.chat_completions.endpoint import CHAT_COMPLETIONS_ENDPOINT + +OPERATION_ID = "createChatCompletion" + + +def export_contract() -> dict[str, Any]: + endpoint = CHAT_COMPLETIONS_ENDPOINT + 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_payload_projection_contract(request_model, "request") + response_contract = validate_payload_projection_contract(response_model, "response") + request = export_payload_schema(request_model, projection_id=request_contract.projection_id) + response = export_payload_schema(response_model, projection_id=response_contract.projection_id) + request[EXTENSION]["stream_selector_field"] = endpoint.guarded_request_model.stream_selector_field + return { + "version": CONTRACT_VERSION, + "operationId": OPERATION_ID, + "profile": request_contract.profile.value, + "request": request, + "response": response, + "integration": { + "name": "chat_completions", + "endpoint": { + "route_path": endpoint.route_path, + "operation_label": endpoint.operation, + "unsupported_request_code": endpoint.unsupported_request_code, + "unsupported_response_code": endpoint.unsupported_response_code, + }, + }, + } + + +def main() -> None: + import yaml + from jsonschema import Draft202012Validator + + parser = argparse.ArgumentParser( + description="Export the handwritten buffered Chat policy; no compiler is required." + ) + destination = parser.add_mutually_exclusive_group() + destination.add_argument("--output", type=Path) + destination.add_argument("--check", type=Path) + args = parser.parse_args() + contract = export_contract() + schema_path = Path(__file__).resolve().parents[3] / "contracts" / "guard-contract.schema.json" + Draft202012Validator(json.loads(schema_path.read_text(encoding="utf-8"))).validate(contract) + 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/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..e4d9f35302 --- /dev/null +++ b/tests/server/experimental/test_openai_chat_contract_export.py @@ -0,0 +1,99 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import subprocess +import sys +from pathlib import Path + +import pytest +import yaml +from jsonschema import Draft202012Validator + +from nemoguardrails.server.experimental.provider.projection_policy import CONTRACT_VERSION, EXTENSION +from nemoguardrails.server.experimental.providers.openai.chat_completions.contract import export_contract +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.providers.openai.chat_completions.contract" + + +def test_buffered_export_matches_checked_in_artifact_and_format(): + contract = export_contract() + 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_contract() + endpoint = CHAT_COMPLETIONS_ENDPOINT + 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_never_reads_the_earlier_authored_contract(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_contract()["operationId"] == "createChatCompletion" + + +def test_export_is_fresh_and_deterministic(): + first = export_contract() + first["request"]["properties"].clear() + second = export_contract() + assert "messages" in second["request"]["properties"] + assert second == export_contract() + + +@pytest.mark.parametrize("annotations", [None, [], [{"provider": "opaque"}]]) +def test_export_preserves_nullable_annotations_and_replacement_policy(annotations): + response = {"choices": [{"message": {"role": "assistant", "content": "answer", "annotations": annotations}}]} + Draft202012Validator(export_contract()["response"]).validate(response) + projection = CHAT_COMPLETIONS_ENDPOINT.guarded_response_model.validate_payload(response) + target = projection.locate_guarded_message(response) + assert target.allows_replacement is (not bool(annotations)) + + +def test_cli_check_and_regenerate(tmp_path): + checked = subprocess.run([sys.executable, "-m", MODULE, "--check", str(EXPORTED)], capture_output=True, text=True) + assert checked.returncode == 0, checked.stderr + output = tmp_path / "exported.guard.yaml" + generated = subprocess.run([sys.executable, "-m", MODULE, "--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([sys.executable, "-m", MODULE, "--check", str(output)], capture_output=True, text=True) + assert mismatch.returncode == 1 + assert "Export differs" in mismatch.stderr From fc11e1f00737fe8d87ef712eb1d43a5b71300f49 Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:20:41 +0200 Subject: [PATCH 06/27] docs(server): document projection policy export boundaries Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- .../chat-completions.buffered.guard.yaml | 5 ++++ .../openai/chat_completions/contract.py | 28 +++++++++++++++++++ .../test_openai_chat_contract_export.py | 2 ++ 3 files changed, 35 insertions(+) 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 index f6da310f24..c9268a9561 100644 --- a/nemoguardrails/server/experimental/contracts/openai/_generated/chat-completions.buffered.guard.yaml +++ b/nemoguardrails/server/experimental/contracts/openai/_generated/chat-completions.buffered.guard.yaml @@ -29,6 +29,8 @@ request: messages: items: additionalProperties: true + description: Accept one user text message with reviewed optional provider + metadata. properties: content: minLength: 1 @@ -161,6 +163,7 @@ response: choices: items: additionalProperties: true + description: Expose the guarded message while preserving opaque choice metadata. properties: finish_reason: default: null @@ -179,6 +182,8 @@ response: reason: provider_integrity.token_logprobs message: additionalProperties: true + description: Accept assistant text while preventing replacement of annotated + content. properties: annotations: default: null diff --git a/nemoguardrails/server/experimental/providers/openai/chat_completions/contract.py b/nemoguardrails/server/experimental/providers/openai/chat_completions/contract.py index 28804fba99..309c0ebd08 100644 --- a/nemoguardrails/server/experimental/providers/openai/chat_completions/contract.py +++ b/nemoguardrails/server/experimental/providers/openai/chat_completions/contract.py @@ -13,6 +13,14 @@ # See the License for the specific language governing permissions and # limitations under the License. +"""Export the buffered Chat contract from the models wired into its endpoint. + +Python declarations remain authoritative. The export describes field policy and +endpoint metadata for readers; it is not loaded by request handling and does not +enable streaming or replacement support. The CLI validates the contract format +and can write an artifact or check a checked-in artifact for drift. +""" + from __future__ import annotations import argparse @@ -34,6 +42,20 @@ def export_contract() -> dict[str, Any]: + """Return a fresh buffered contract from the endpoint's bound policy models. + + Model schemas supply constraints, defaults, and guard annotations; the + endpoint supplies its route, label, and rejection codes. No authored YAML + is read. Streaming is intentionally absent from this buffered integration. + + Raises: + TypeError: An endpoint model does not carry projection policy. + ValueError: Runtime coverage metadata is invalid or a payload schema + uses a construct unsupported by the shared exporter. + + The CLI performs document-format validation. This function does not prove + upstream compatibility or encode arbitrary Python validation behavior. + """ endpoint = CHAT_COMPLETIONS_ENDPOINT request_model = endpoint.guarded_request_model response_model = endpoint.guarded_response_model @@ -63,6 +85,12 @@ def export_contract() -> dict[str, Any]: def main() -> None: + """Validate and emit the contract, or check an artifact for exact drift. + + 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 the YAML document to stdout. + """ import yaml from jsonschema import Draft202012Validator diff --git a/tests/server/experimental/test_openai_chat_contract_export.py b/tests/server/experimental/test_openai_chat_contract_export.py index e4d9f35302..4a57082067 100644 --- a/tests/server/experimental/test_openai_chat_contract_export.py +++ b/tests/server/experimental/test_openai_chat_contract_export.py @@ -13,6 +13,8 @@ # 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 19a2b9cda412e42003bb9b6a7512518f1273a839 Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:59:37 +0200 Subject: [PATCH 07/27] docs(server): align buffered export with contract cleanup Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- .../providers/openai/chat_completions/contract.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/nemoguardrails/server/experimental/providers/openai/chat_completions/contract.py b/nemoguardrails/server/experimental/providers/openai/chat_completions/contract.py index 309c0ebd08..675205224a 100644 --- a/nemoguardrails/server/experimental/providers/openai/chat_completions/contract.py +++ b/nemoguardrails/server/experimental/providers/openai/chat_completions/contract.py @@ -94,9 +94,7 @@ def main() -> None: import yaml from jsonschema import Draft202012Validator - parser = argparse.ArgumentParser( - description="Export the handwritten buffered Chat policy; no compiler is required." - ) + parser = argparse.ArgumentParser(description="Export the buffered Chat policy from its Python declarations.") destination = parser.add_mutually_exclusive_group() destination.add_argument("--output", type=Path) destination.add_argument("--check", type=Path) From 852674efd8108d3d9414513cac96b727dc444702 Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:49:05 +0200 Subject: [PATCH 08/27] refactor(server): share buffered guard contract export Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- .../experimental/provider/contract_export.py | 162 +++++++++++++++ .../openai/chat_completions/README.md | 32 ++- .../openai/chat_completions/contract.py | 117 ----------- .../experimental/test_contract_export.py | 192 ++++++++++++++++++ .../test_openai_chat_contract_export.py | 88 ++++++-- 5 files changed, 458 insertions(+), 133 deletions(-) create mode 100644 nemoguardrails/server/experimental/provider/contract_export.py delete mode 100644 nemoguardrails/server/experimental/providers/openai/chat_completions/contract.py create mode 100644 tests/server/experimental/test_contract_export.py diff --git a/nemoguardrails/server/experimental/provider/contract_export.py b/nemoguardrails/server/experimental/provider/contract_export.py new file mode 100644 index 0000000000..44cfe83a77 --- /dev/null +++ b/nemoguardrails/server/experimental/provider/contract_export.py @@ -0,0 +1,162 @@ +# 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 re +import sys +from pathlib import Path +from typing import Any + +from nemoguardrails.server.experimental.provider.endpoint import GuardedJsonEndpoint +from nemoguardrails.server.experimental.provider.payload import validate_payload_projection_contract +from nemoguardrails.server.experimental.provider.projection_policy import ( + CONTRACT_VERSION, + EXTENSION, + PolicyModel, + export_payload_schema, +) + + +def export_guard_contract( + endpoint: GuardedJsonEndpoint, *, operation_id: str, name: str | None = None +) -> 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. + operation_id: Stable provider operation identity for the document. + name: Optional integration identifier, using lowercase snake case. + + 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: Identity or projection metadata is invalid, or a payload + schema uses a construct unsupported by the shared schema exporter. + + 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") + if not operation_id.strip(): + raise ValueError("Contract operation_id must not be blank") + if name is not None and re.fullmatch(r"[a-z][a-z0-9_]*", name) is None: + raise ValueError("Contract name must use lowercase snake case") + 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_payload_projection_contract(request_model, "request") + response_contract = validate_payload_projection_contract(response_model, "response") + 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 name is not None: + integration["name"] = 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": 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) 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)") + parser.add_argument("--operation-id", required=True, help="provider operation identity") + parser.add_argument("--name", help="optional integration identifier") + 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, operation_id=args.operation_id, name=args.name) + 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/providers/openai/chat_completions/README.md b/nemoguardrails/server/experimental/providers/openai/chat_completions/README.md index 4f1cd8a2e0..615232b8f1 100644 --- a/nemoguardrails/server/experimental/providers/openai/chat_completions/README.md +++ b/nemoguardrails/server/experimental/providers/openai/chat_completions/README.md @@ -42,16 +42,38 @@ scope and provider provenance. Neither is loaded by the models or bindings. ## Buffered contract export -Run the exporter from the repository root: +The shared [contract exporter](../../../provider/contract_export.py) takes the +runtime endpoint and its document identity. No per-operation exporter is needed: + +```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, + operation_id="createChatCompletion", + name="chat_completions", +) +``` + +Run the shared CLI from the repository root: ```bash -poetry run python -m nemoguardrails.server.experimental.providers.openai.chat_completions.contract \ +poetry run python -m nemoguardrails.server.experimental.provider.contract_export \ + nemoguardrails.server.experimental.providers.openai.chat_completions.endpoint:CHAT_COMPLETIONS_ENDPOINT \ + --operation-id createChatCompletion --name chat_completions \ --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 existing contract format before writing. The optional `--name` identifies +the integration; `--operation-id` identifies the provider 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 @@ -62,6 +84,10 @@ 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. +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. No new contract format version, provider download, or runtime YAML loading is introduced. diff --git a/nemoguardrails/server/experimental/providers/openai/chat_completions/contract.py b/nemoguardrails/server/experimental/providers/openai/chat_completions/contract.py deleted file mode 100644 index 675205224a..0000000000 --- a/nemoguardrails/server/experimental/providers/openai/chat_completions/contract.py +++ /dev/null @@ -1,117 +0,0 @@ -# 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 the buffered Chat contract from the models wired into its endpoint. - -Python declarations remain authoritative. The export describes field policy and -endpoint metadata for readers; it is not loaded by request handling and does not -enable streaming or replacement support. The CLI validates the contract format -and can write an artifact or check a checked-in artifact for drift. -""" - -from __future__ import annotations - -import argparse -import json -import sys -from pathlib import Path -from typing import Any - -from nemoguardrails.server.experimental.provider.payload import validate_payload_projection_contract -from nemoguardrails.server.experimental.provider.projection_policy import ( - CONTRACT_VERSION, - EXTENSION, - PolicyModel, - export_payload_schema, -) -from nemoguardrails.server.experimental.providers.openai.chat_completions.endpoint import CHAT_COMPLETIONS_ENDPOINT - -OPERATION_ID = "createChatCompletion" - - -def export_contract() -> dict[str, Any]: - """Return a fresh buffered contract from the endpoint's bound policy models. - - Model schemas supply constraints, defaults, and guard annotations; the - endpoint supplies its route, label, and rejection codes. No authored YAML - is read. Streaming is intentionally absent from this buffered integration. - - Raises: - TypeError: An endpoint model does not carry projection policy. - ValueError: Runtime coverage metadata is invalid or a payload schema - uses a construct unsupported by the shared exporter. - - The CLI performs document-format validation. This function does not prove - upstream compatibility or encode arbitrary Python validation behavior. - """ - endpoint = CHAT_COMPLETIONS_ENDPOINT - 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_payload_projection_contract(request_model, "request") - response_contract = validate_payload_projection_contract(response_model, "response") - request = export_payload_schema(request_model, projection_id=request_contract.projection_id) - response = export_payload_schema(response_model, projection_id=response_contract.projection_id) - request[EXTENSION]["stream_selector_field"] = endpoint.guarded_request_model.stream_selector_field - return { - "version": CONTRACT_VERSION, - "operationId": OPERATION_ID, - "profile": request_contract.profile.value, - "request": request, - "response": response, - "integration": { - "name": "chat_completions", - "endpoint": { - "route_path": endpoint.route_path, - "operation_label": endpoint.operation, - "unsupported_request_code": endpoint.unsupported_request_code, - "unsupported_response_code": endpoint.unsupported_response_code, - }, - }, - } - - -def main() -> None: - """Validate and emit the contract, or check an artifact for exact drift. - - 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 the YAML document to stdout. - """ - import yaml - from jsonschema import Draft202012Validator - - parser = argparse.ArgumentParser(description="Export the buffered Chat policy from its Python declarations.") - destination = parser.add_mutually_exclusive_group() - destination.add_argument("--output", type=Path) - destination.add_argument("--check", type=Path) - args = parser.parse_args() - contract = export_contract() - schema_path = Path(__file__).resolve().parents[3] / "contracts" / "guard-contract.schema.json" - Draft202012Validator(json.loads(schema_path.read_text(encoding="utf-8"))).validate(contract) - 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/tests/server/experimental/test_contract_export.py b/tests/server/experimental/test_contract_export.py new file mode 100644 index 0000000000..089545a5a5 --- /dev/null +++ b/tests/server/experimental/test_contract_export.py @@ -0,0 +1,192 @@ +# 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 +from nemoguardrails.server.experimental.provider.projection_policy import ( + EXTENSION, + PolicyModel, + guarded, + 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( + 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, operation_id="createExampleText") + 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 + + +def test_export_follows_changed_endpoint_metadata(example_endpoint): + """An endpoint variant changes the export without a provider-specific wrapper.""" + endpoint = replace( + example_endpoint, + route_path="/v3/text", + operation_paths=(), + operation="Different text operation", + unsupported_response_code="different_response_error", + ) + contract = export_guard_contract(endpoint, operation_id="otherOperation", name="other_text") + 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" + + +@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, operation_id="example") + + +@pytest.mark.parametrize( + ("operation_id", "name", "message"), + [("", None, "must not be blank"), (" ", None, "must not be blank"), ("example", "Bad.Name", "snake case")], +) +def test_export_rejects_invalid_document_identity(example_endpoint, operation_id, name, message): + """Reject malformed operation metadata independently of serialization.""" + with pytest.raises(ValueError, match=message): + export_guard_contract(example_endpoint, operation_id=operation_id, 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 diff --git a/tests/server/experimental/test_openai_chat_contract_export.py b/tests/server/experimental/test_openai_chat_contract_export.py index 4a57082067..7ebe066c30 100644 --- a/tests/server/experimental/test_openai_chat_contract_export.py +++ b/tests/server/experimental/test_openai_chat_contract_export.py @@ -24,18 +24,22 @@ import yaml from jsonschema import Draft202012Validator +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.contract import export_contract 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.providers.openai.chat_completions.contract" +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, "--operation-id", "createChatCompletion", "--name", "chat_completions"] def test_buffered_export_matches_checked_in_artifact_and_format(): - contract = export_contract() + contract = export_guard_contract( + CHAT_COMPLETIONS_ENDPOINT, operation_id="createChatCompletion", name="chat_completions" + ) 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) @@ -45,7 +49,9 @@ def test_buffered_export_matches_checked_in_artifact_and_format(): def test_export_uses_the_runtime_endpoint_metadata(): - exported = export_contract() + exported = export_guard_contract( + CHAT_COMPLETIONS_ENDPOINT, operation_id="createChatCompletion", name="chat_completions" + ) endpoint = CHAT_COMPLETIONS_ENDPOINT assert exported["integration"]["endpoint"] == { "route_path": endpoint.route_path, @@ -63,39 +69,95 @@ def test_export_uses_the_runtime_endpoint_metadata(): ) -def test_export_never_reads_the_earlier_authored_contract(monkeypatch): +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_contract()["operationId"] == "createChatCompletion" + assert ( + export_guard_contract(CHAT_COMPLETIONS_ENDPOINT, operation_id="createChatCompletion", name="chat_completions")[ + "operationId" + ] + == "createChatCompletion" + ) def test_export_is_fresh_and_deterministic(): - first = export_contract() + first = export_guard_contract( + CHAT_COMPLETIONS_ENDPOINT, operation_id="createChatCompletion", name="chat_completions" + ) first["request"]["properties"].clear() - second = export_contract() + second = export_guard_contract( + CHAT_COMPLETIONS_ENDPOINT, operation_id="createChatCompletion", name="chat_completions" + ) assert "messages" in second["request"]["properties"] - assert second == export_contract() + assert second == export_guard_contract( + CHAT_COMPLETIONS_ENDPOINT, operation_id="createChatCompletion", name="chat_completions" + ) @pytest.mark.parametrize("annotations", [None, [], [{"provider": "opaque"}]]) def test_export_preserves_nullable_annotations_and_replacement_policy(annotations): response = {"choices": [{"message": {"role": "assistant", "content": "answer", "annotations": annotations}}]} - Draft202012Validator(export_contract()["response"]).validate(response) + Draft202012Validator( + export_guard_contract(CHAT_COMPLETIONS_ENDPOINT, operation_id="createChatCompletion", name="chat_completions")[ + "response" + ] + ).validate(response) projection = CHAT_COMPLETIONS_ENDPOINT.guarded_response_model.validate_payload(response) target = projection.locate_guarded_message(response) assert target.allows_replacement is (not bool(annotations)) def test_cli_check_and_regenerate(tmp_path): - checked = subprocess.run([sys.executable, "-m", MODULE, "--check", str(EXPORTED)], capture_output=True, text=True) + 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([sys.executable, "-m", MODULE, "--output", str(output)], capture_output=True, text=True) + 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([sys.executable, "-m", MODULE, "--check", str(output)], capture_output=True, text=True) + 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_invalid_identity_does_not_overwrite_an_artifact(tmp_path, arguments): + """Bad metadata fails before an existing destination can be overwritten.""" + 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", "--operation-id", "example", "--output", str(destination)], + capture_output=True, + text=True, + ) + assert completed.returncode == 2 + assert "not a GuardedJsonEndpoint" in completed.stderr + assert not destination.exists() From 84497062f70fdc482e4e936a3eea8383ef251374 Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:06:31 +0200 Subject: [PATCH 09/27] fix(server): align buffered Chat integration with closed projections After the closed Chat projections merged, the buffered integration fixtures relied on an unreviewed top-level "opaque" member, which the request and response roots now reject. The unsupported-response example used tool_calls: [], which is now a valid empty value. The checked-in contract export no longer matched the policy models. Use reviewed opaque fields (metadata and usage) to show byte-preserving relay, use an actual tool call as the unsupported response, and regenerate the export with the contract export CLI. The export and the runtime now both accept only null or empty annotations. Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- .../chat-completions.buffered.guard.yaml | 179 ++++++++++++++---- .../test_openai_chat_contract_export.py | 18 +- .../test_openai_chat_integration.py | 6 +- 3 files changed, 152 insertions(+), 51 deletions(-) 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 index c9268a9561..67c8276373 100644 --- a/nemoguardrails/server/experimental/contracts/openai/_generated/chat-completions.buffered.guard.yaml +++ b/nemoguardrails/server/experimental/contracts/openai/_generated/chat-completions.buffered.guard.yaml @@ -2,7 +2,7 @@ version: 1.0.0-alpha.1 operationId: createChatCompletion profile: single_text.v1 request: - additionalProperties: true + additionalProperties: false description: Bind the handwritten projection to its guarded runtime semantics. properties: audio: @@ -12,6 +12,9 @@ request: classification: constrained gate: disabled reason: core_capability.audio_content + frequency_penalty: + x-nemo-guardrails: + classification: opaque function_call: default: null type: 'null' @@ -26,6 +29,24 @@ request: 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: true @@ -43,8 +64,11 @@ request: role: user name: default: null + type: 'null' x-nemo-guardrails: - classification: opaque + classification: constrained + gate: disabled + reason: core_capability.participant_name role: const: user type: string @@ -63,6 +87,9 @@ request: type: array x-nemo-guardrails: classification: guarded + metadata: + x-nemo-guardrails: + classification: opaque modalities: default: null type: 'null' @@ -70,6 +97,12 @@ request: 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 @@ -91,6 +124,33 @@ request: 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' @@ -98,11 +158,32 @@ request: 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' @@ -117,6 +198,22 @@ request: 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' @@ -129,35 +226,10 @@ request: title: openai.chat_completions.request.text.v1 type: object x-nemo-guardrails: - opaque_fields: - - frequency_penalty - - logit_bias - - logprobs - - max_completion_tokens - - max_tokens - - metadata - - model - - moderation - - presence_penalty - - prompt_cache_key - - prompt_cache_options - - prompt_cache_retention - - reasoning_effort - - safety_identifier - - seed - - service_tier - - stop - - store - - stream_options - - temperature - - top_logprobs - - top_p - - user - - verbosity model: ChatCompletionsGuardedRequest stream_selector_field: stream response: - additionalProperties: true + additionalProperties: false description: Bind the handwritten projection to its guarded runtime semantics. properties: choices: @@ -189,8 +261,10 @@ response: default: null x-nemo-guardrails: classification: constrained + reason: core_capability.citation_content oneOf: - items: {} + maxItems: 0 type: array - type: 'null' audio: @@ -201,8 +275,6 @@ response: gate: disabled reason: core_capability.audio_content content: - minLength: 1 - type: string x-nemo-guardrails: classification: guarded subject: @@ -211,6 +283,9 @@ response: replacement_blocked_by: annotations replacement_reason: provider_integrity.annotated_text role: assistant + oneOf: + - type: string + - type: 'null' function_call: default: null type: 'null' @@ -240,11 +315,14 @@ response: classification: constrained tool_calls: default: null - type: 'null' x-nemo-guardrails: classification: constrained - gate: disabled reason: core_capability.tool_content + oneOf: + - items: {} + maxItems: 0 + type: array + - type: 'null' required: - content - role @@ -265,21 +343,38 @@ response: 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: - opaque_fields: - - created - - id - - metadata - - model - - moderation - - object - - service_tier - - system_fingerprint - - usage model: ChatCompletionsGuardedResponse integration: name: chat_completions diff --git a/tests/server/experimental/test_openai_chat_contract_export.py b/tests/server/experimental/test_openai_chat_contract_export.py index 7ebe066c30..e98d842bd6 100644 --- a/tests/server/experimental/test_openai_chat_contract_export.py +++ b/tests/server/experimental/test_openai_chat_contract_export.py @@ -23,6 +23,7 @@ import pytest import yaml from jsonschema import Draft202012Validator +from pydantic import ValidationError from nemoguardrails.server.experimental.provider.contract_export import export_guard_contract from nemoguardrails.server.experimental.provider.projection_policy import CONTRACT_VERSION, EXTENSION @@ -96,17 +97,22 @@ def test_export_is_fresh_and_deterministic(): ) -@pytest.mark.parametrize("annotations", [None, [], [{"provider": "opaque"}]]) -def test_export_preserves_nullable_annotations_and_replacement_policy(annotations): +@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}}]} - Draft202012Validator( + validator = Draft202012Validator( export_guard_contract(CHAT_COMPLETIONS_ENDPOINT, operation_id="createChatCompletion", name="chat_completions")[ "response" ] - ).validate(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) - target = projection.locate_guarded_message(response) - assert target.allows_replacement is (not bool(annotations)) + assert projection.locate_guarded_message(response).allows_replacement is True def test_cli_check_and_regenerate(tmp_path): diff --git a/tests/server/experimental/test_openai_chat_integration.py b/tests/server/experimental/test_openai_chat_integration.py index 636017ffd7..da17c56134 100644 --- a/tests/server/experimental/test_openai_chat_integration.py +++ b/tests/server/experimental/test_openai_chat_integration.py @@ -64,12 +64,12 @@ async def check_output(self, check): def _request_body(): """Return a representative OpenAI Chat request body.""" - return b'{ "messages" : [ { "role" : "user", "content" : "question" } ], "model" : "gpt-example", "opaque" : { "keep" : true } }' + 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" } } ], "opaque" : [ 1, 2 ] }' + return b'{ "id" : "chatcmpl-example", "choices" : [ { "index" : 0, "message" : { "role" : "assistant", "content" : "answer" } } ], "usage" : { "total_tokens" : 2 } }' def _json_headers(*headers): @@ -261,7 +261,7 @@ async def dispatch(request): @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":[]}}]}' + 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.""" From 57586c1e6a546f845b92510e24211c04f688dc7c Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:03:42 +0200 Subject: [PATCH 10/27] fix(server): relay buffered Chat responses that have no text The response projection accepts empty or null assistant content, as OpenAI's schema allows, for example when generation stops at a length limit or a content filter. The buffered proxy still read the target's message, which raises for null content and runs output rails on an empty string. After shape validation, check has_text and return ContentInspectionNotApplicable when there is no text, so the kernel relays the original response without output checks. Validation still runs first: a missing message or missing content key is rejected, and null content with refusal, reasoning, tool, or citation content is rejected. Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- .../server/experimental/_guarded_proxy.py | 7 +- .../test_openai_chat_integration.py | 71 +++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/nemoguardrails/server/experimental/_guarded_proxy.py b/nemoguardrails/server/experimental/_guarded_proxy.py index 658d4e46b6..5434bfea5b 100644 --- a/nemoguardrails/server/experimental/_guarded_proxy.py +++ b/nemoguardrails/server/experimental/_guarded_proxy.py @@ -163,7 +163,12 @@ def project_response( try: document = parse_json_object(payload.body) projection = endpoint.guarded_response_model.validate_payload(document) - return projection.locate_guarded_message(document).message + 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")) from error except (InvalidJson, UnsupportedJsonShape) as error: diff --git a/tests/server/experimental/test_openai_chat_integration.py b/tests/server/experimental/test_openai_chat_integration.py index da17c56134..50b0ddd786 100644 --- a/tests/server/experimental/test_openai_chat_integration.py +++ b/tests/server/experimental/test_openai_chat_integration.py @@ -13,6 +13,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +import json + import httpx import pytest import pytest_asyncio @@ -234,6 +236,75 @@ async def dispatch(_request): assert "x-provider-secret" not in response.headers +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.""" From 960dab5f8d467285a1d337ec7d60f829547b7401 Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:03:42 +0200 Subject: [PATCH 11/27] fix(server): request identity-encoded responses for guarded Chat Clients such as the OpenAI SDK send Accept-Encoding: gzip, and the proxy forwarded it. A provider that compressed the response then failed output inspection with a 502, because encoded successful responses cannot be inspected. Replace Accept-Encoding with identity on guarded provider requests. A provider that still encodes the response is rejected rather than relayed without inspection. Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- .../server/experimental/_guarded_proxy.py | 18 ++++++++- .../test_openai_chat_integration.py | 37 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/nemoguardrails/server/experimental/_guarded_proxy.py b/nemoguardrails/server/experimental/_guarded_proxy.py index 5434bfea5b..96ea6e2b82 100644 --- a/nemoguardrails/server/experimental/_guarded_proxy.py +++ b/nemoguardrails/server/experimental/_guarded_proxy.py @@ -67,6 +67,18 @@ def _header(headers: tuple[tuple[bytes, bytes], ...], name: bytes) -> str | None return values[-1] if values else None +def _request_identity_encoding(headers: tuple[tuple[bytes, bytes], ...]) -> tuple[tuple[bytes, bytes], ...]: + """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. + """ + return ( + *((key, value) for key, value in headers if key.lower() != b"accept-encoding"), + (b"accept-encoding", b"identity"), + ) + + 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 @@ -147,7 +159,11 @@ def project_request(payload: GuardableProviderRequest) -> GuardedMessage: def forward_request(request: GuardableProviderRequest) -> BufferedHttpRequest: """Return the provider request that should be dispatched.""" prepared = _prepare_provider_request(request) - return replace(request.request, body=prepared.body) + return replace( + request.request, + body=prepared.body, + headers=_request_identity_encoding(request.request.headers), + ) def project_response( payload: BufferedHttpResponse, diff --git a/tests/server/experimental/test_openai_chat_integration.py b/tests/server/experimental/test_openai_chat_integration.py index 50b0ddd786..adfde6f613 100644 --- a/tests/server/experimental/test_openai_chat_integration.py +++ b/tests/server/experimental/test_openai_chat_integration.py @@ -236,6 +236,43 @@ async def dispatch(_request): 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.")) From 9ff86859e1fd15daf9bc3d09b1287ab555cd104a Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:11:25 +0200 Subject: [PATCH 12/27] docs(server): describe buffered Chat relay and encoding behavior The OpenAI boundary summary said relaying responses without text was left to the integration, which now does it. The buffered integration docs did not describe the relay or the identity-encoding request, and still showed a Poetry command for the contract export CLI. State that empty or null content is relayed without output checks only after the closed projection passes. Document the identity-encoding request and the rejection of encoded responses. Use uv for the export command, and note that exported annotations allow only an empty list. Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- .../server/experimental/contracts/openai/README.md | 2 +- .../providers/openai/chat_completions/README.md | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) 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/providers/openai/chat_completions/README.md b/nemoguardrails/server/experimental/providers/openai/chat_completions/README.md index 615232b8f1..c80938f6ee 100644 --- a/nemoguardrails/server/experimental/providers/openai/chat_completions/README.md +++ b/nemoguardrails/server/experimental/providers/openai/chat_completions/README.md @@ -59,7 +59,7 @@ contract = export_guard_contract( Run the shared CLI from the repository root: ```bash -poetry run python -m nemoguardrails.server.experimental.provider.contract_export \ +uv run --locked python -m nemoguardrails.server.experimental.provider.contract_export \ nemoguardrails.server.experimental.providers.openai.chat_completions.endpoint:CHAT_COMPLETIONS_ENDPOINT \ --operation-id createChatCompletion --name chat_completions \ --output nemoguardrails/server/experimental/contracts/openai/_generated/chat-completions.buffered.guard.yaml @@ -84,10 +84,20 @@ 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. + +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. + 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. +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. From e1da40f732e8a6b2c4c88c6603a7d17d831322e0 Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:15:41 +0200 Subject: [PATCH 13/27] refactor(server): derive contract identity from provider endpoints Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- .../experimental/provider/contract_export.py | 23 +++------- .../server/experimental/provider/endpoint.py | 7 +++ .../openai/chat_completions/README.md | 14 +++--- .../openai/chat_completions/endpoint.py | 2 + .../experimental/test_contract_export.py | 14 +++--- .../test_openai_chat_contract_export.py | 43 ++++++------------- 6 files changed, 42 insertions(+), 61 deletions(-) diff --git a/nemoguardrails/server/experimental/provider/contract_export.py b/nemoguardrails/server/experimental/provider/contract_export.py index 44cfe83a77..1cf3974726 100644 --- a/nemoguardrails/server/experimental/provider/contract_export.py +++ b/nemoguardrails/server/experimental/provider/contract_export.py @@ -31,7 +31,6 @@ import argparse import importlib import json -import re import sys from pathlib import Path from typing import Any @@ -46,16 +45,12 @@ ) -def export_guard_contract( - endpoint: GuardedJsonEndpoint, *, operation_id: str, name: str | None = None -) -> dict[str, Any]: +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. - operation_id: Stable provider operation identity for the document. - name: Optional integration identifier, using lowercase snake case. Returns: A fresh document with payload schemas, profile, and endpoint labels. @@ -73,10 +68,6 @@ def export_guard_contract( """ if not isinstance(endpoint, GuardedJsonEndpoint): raise TypeError("Contract export requires a GuardedJsonEndpoint") - if not operation_id.strip(): - raise ValueError("Contract operation_id must not be blank") - if name is not None and re.fullmatch(r"[a-z][a-z0-9_]*", name) is None: - raise ValueError("Contract name must use lowercase snake case") request_model = endpoint.guarded_request_model response_model = endpoint.guarded_response_model if not issubclass(request_model, PolicyModel) or not issubclass(response_model, PolicyModel): @@ -88,8 +79,8 @@ def export_guard_contract( if request_model.stream_selector_field is not None: request[EXTENSION]["stream_selector_field"] = request_model.stream_selector_field integration: dict[str, Any] = {} - if name is not None: - integration["name"] = name + if endpoint.contract_name is not None: + integration["name"] = endpoint.contract_name integration["endpoint"] = { "route_path": endpoint.route_path, "operation_label": endpoint.operation, @@ -98,7 +89,7 @@ def export_guard_contract( } return { "version": CONTRACT_VERSION, - "operationId": operation_id, + "operationId": endpoint.provider_operation_id, "profile": request_contract.profile.value, "request": request, "response": response, @@ -113,7 +104,7 @@ def _load_endpoint(reference: str) -> GuardedJsonEndpoint: raise argparse.ArgumentTypeError("Endpoint must be a Python module:attribute reference") try: endpoint = getattr(importlib.import_module(module), attribute) - except (ImportError, AttributeError) as error: + 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") @@ -130,8 +121,6 @@ def main() -> None: """ 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)") - parser.add_argument("--operation-id", required=True, help="provider operation identity") - parser.add_argument("--name", help="optional integration identifier") destination = parser.add_mutually_exclusive_group() destination.add_argument("--output", type=Path) destination.add_argument("--check", type=Path) @@ -143,7 +132,7 @@ def main() -> None: schema_path = Path(__file__).resolve().parents[1] / "contracts" / "guard-contract.schema.json" try: - contract = export_guard_contract(args.endpoint, operation_id=args.operation_id, name=args.name) + 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)) diff --git a/nemoguardrails/server/experimental/provider/endpoint.py b/nemoguardrails/server/experimental/provider/endpoint.py index 7759bb1ec4..1d3b5200d1 100644 --- a/nemoguardrails/server/experimental/provider/endpoint.py +++ b/nemoguardrails/server/experimental/provider/endpoint.py @@ -15,6 +15,7 @@ """Declare provider endpoints handled by the guarded JSON pipeline.""" +import re from dataclasses import dataclass from nemoguardrails.server.experimental._http_kernel import GuardedOperationPath @@ -37,12 +38,18 @@ class GuardedJsonEndpoint: 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(): diff --git a/nemoguardrails/server/experimental/providers/openai/chat_completions/README.md b/nemoguardrails/server/experimental/providers/openai/chat_completions/README.md index c80938f6ee..f5c0cb2df6 100644 --- a/nemoguardrails/server/experimental/providers/openai/chat_completions/README.md +++ b/nemoguardrails/server/experimental/providers/openai/chat_completions/README.md @@ -43,17 +43,13 @@ 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 and its document identity. No per-operation exporter is needed: +runtime endpoint, including its declared document identity. No per-operation exporter is needed: ```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, - operation_id="createChatCompletion", - name="chat_completions", -) +contract = export_guard_contract(CHAT_COMPLETIONS_ENDPOINT) ``` Run the shared CLI from the repository root: @@ -61,14 +57,14 @@ 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 \ - --operation-id createChatCompletion --name chat_completions \ --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 optional `--name` identifies -the integration; `--operation-id` identifies the provider operation. +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 diff --git a/nemoguardrails/server/experimental/providers/openai/chat_completions/endpoint.py b/nemoguardrails/server/experimental/providers/openai/chat_completions/endpoint.py index 399804f8db..1663b501b9 100644 --- a/nemoguardrails/server/experimental/providers/openai/chat_completions/endpoint.py +++ b/nemoguardrails/server/experimental/providers/openai/chat_completions/endpoint.py @@ -25,6 +25,8 @@ ) 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", diff --git a/tests/server/experimental/test_contract_export.py b/tests/server/experimental/test_contract_export.py index 089545a5a5..1ea654ae25 100644 --- a/tests/server/experimental/test_contract_export.py +++ b/tests/server/experimental/test_contract_export.py @@ -61,6 +61,7 @@ class Response(PolicyModel, GuardedPayloadModel): 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", @@ -73,7 +74,7 @@ class Response(PolicyModel, GuardedPayloadModel): 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, operation_id="createExampleText") + contract = export_guard_contract(example_endpoint) schema = json.loads( (ROOT / "nemoguardrails/server/experimental/contracts/guard-contract.schema.json").read_text(encoding="utf-8") ) @@ -99,12 +100,15 @@ 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, operation_id="otherOperation", name="other_text") + 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" @@ -136,17 +140,17 @@ class PlainResponse(GuardedPayloadModel): endpoint = replace(example_endpoint, guarded_response_model=PlainResponse) with pytest.raises(TypeError, match="policy-annotated"): - export_guard_contract(endpoint, operation_id="example") + 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_export_rejects_invalid_document_identity(example_endpoint, operation_id, name, message): +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): - export_guard_contract(example_endpoint, operation_id=operation_id, name=name) + replace(example_endpoint, provider_operation_id=operation_id, contract_name=name) @pytest.mark.parametrize("reference", ["missing_separator", ":value", "pathlib:Path()", "pathlib:Path:extra"]) diff --git a/tests/server/experimental/test_openai_chat_contract_export.py b/tests/server/experimental/test_openai_chat_contract_export.py index e98d842bd6..546879293f 100644 --- a/tests/server/experimental/test_openai_chat_contract_export.py +++ b/tests/server/experimental/test_openai_chat_contract_export.py @@ -34,13 +34,11 @@ 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, "--operation-id", "createChatCompletion", "--name", "chat_completions"] +CLI = [sys.executable, "-m", MODULE, ENDPOINT] def test_buffered_export_matches_checked_in_artifact_and_format(): - contract = export_guard_contract( - CHAT_COMPLETIONS_ENDPOINT, operation_id="createChatCompletion", name="chat_completions" - ) + 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) @@ -50,10 +48,10 @@ def test_buffered_export_matches_checked_in_artifact_and_format(): def test_export_uses_the_runtime_endpoint_metadata(): - exported = export_guard_contract( - CHAT_COMPLETIONS_ENDPOINT, operation_id="createChatCompletion", name="chat_completions" - ) + 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, @@ -75,36 +73,21 @@ 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, operation_id="createChatCompletion", name="chat_completions")[ - "operationId" - ] - == "createChatCompletion" - ) + assert export_guard_contract(CHAT_COMPLETIONS_ENDPOINT)["operationId"] == "createChatCompletion" def test_export_is_fresh_and_deterministic(): - first = export_guard_contract( - CHAT_COMPLETIONS_ENDPOINT, operation_id="createChatCompletion", name="chat_completions" - ) + first = export_guard_contract(CHAT_COMPLETIONS_ENDPOINT) first["request"]["properties"].clear() - second = export_guard_contract( - CHAT_COMPLETIONS_ENDPOINT, operation_id="createChatCompletion", name="chat_completions" - ) + second = export_guard_contract(CHAT_COMPLETIONS_ENDPOINT) assert "messages" in second["request"]["properties"] - assert second == export_guard_contract( - CHAT_COMPLETIONS_ENDPOINT, operation_id="createChatCompletion", name="chat_completions" - ) + 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, operation_id="createChatCompletion", name="chat_completions")[ - "response" - ] - ) + validator = Draft202012Validator(export_guard_contract(CHAT_COMPLETIONS_ENDPOINT)["response"]) if annotations: assert not validator.is_valid(response) with pytest.raises(ValidationError): @@ -146,8 +129,8 @@ def test_cli_missing_check_does_not_create_an_artifact(tmp_path): @pytest.mark.parametrize("arguments", [["--operation-id", ""], ["--name", "Bad.Name"]]) -def test_cli_invalid_identity_does_not_overwrite_an_artifact(tmp_path, arguments): - """Bad metadata fails before an existing destination can be overwritten.""" +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) @@ -160,7 +143,7 @@ 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", "--operation-id", "example", "--output", str(destination)], + [sys.executable, "-m", MODULE, "pathlib:Path", "--output", str(destination)], capture_output=True, text=True, ) From ec4133bc2b870c4af028012ea3af203ebe1e4ac5 Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:20:35 +0200 Subject: [PATCH 14/27] refactor(server): isolate guarded route declarations from HTTP execution Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- .../server/experimental/_http_kernel.py | 32 +-------- .../server/experimental/_http_paths.py | 66 +++++++++++++++++++ .../server/experimental/provider/endpoint.py | 2 +- .../openai/chat_completions/endpoint.py | 2 +- .../experimental/test_contract_export.py | 19 ++++++ tests/server/experimental/test_http_kernel.py | 44 +++++++++++++ 6 files changed, 133 insertions(+), 32 deletions(-) create mode 100644 nemoguardrails/server/experimental/_http_paths.py diff --git a/nemoguardrails/server/experimental/_http_kernel.py b/nemoguardrails/server/experimental/_http_kernel.py index df3c565767..83d29b4fe6 100644 --- a/nemoguardrails/server/experimental/_http_kernel.py +++ b/nemoguardrails/server/experimental/_http_kernel.py @@ -23,7 +23,7 @@ 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 @@ -42,8 +42,8 @@ validate_content_checker, ) 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], ...] @@ -146,34 +146,6 @@ 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.""" 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/provider/endpoint.py b/nemoguardrails/server/experimental/provider/endpoint.py index 1d3b5200d1..2f31cda6c4 100644 --- a/nemoguardrails/server/experimental/provider/endpoint.py +++ b/nemoguardrails/server/experimental/provider/endpoint.py @@ -18,7 +18,7 @@ import re from dataclasses import dataclass -from nemoguardrails.server.experimental._http_kernel import GuardedOperationPath +from nemoguardrails.server.experimental._http_paths import GuardedOperationPath from nemoguardrails.server.experimental.provider.payload import ( GuardedPayloadModel, GuardedRequestModel, diff --git a/nemoguardrails/server/experimental/providers/openai/chat_completions/endpoint.py b/nemoguardrails/server/experimental/providers/openai/chat_completions/endpoint.py index 1663b501b9..7cb88dd80e 100644 --- a/nemoguardrails/server/experimental/providers/openai/chat_completions/endpoint.py +++ b/nemoguardrails/server/experimental/providers/openai/chat_completions/endpoint.py @@ -15,7 +15,7 @@ """Staged guarded endpoint declaration for OpenAI Chat Completions.""" -from nemoguardrails.server.experimental._http_kernel import GuardedOperationPath +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, diff --git a/tests/server/experimental/test_contract_export.py b/tests/server/experimental/test_contract_export.py index 1ea654ae25..7903e0b260 100644 --- a/tests/server/experimental/test_contract_export.py +++ b/tests/server/experimental/test_contract_export.py @@ -194,3 +194,22 @@ def test_export_module_import_does_not_load_provider_integrations(): 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 diff --git a/tests/server/experimental/test_http_kernel.py b/tests/server/experimental/test_http_kernel.py index 204823b257..76aec1e879 100644 --- a/tests/server/experimental/test_http_kernel.py +++ b/tests/server/experimental/test_http_kernel.py @@ -1372,3 +1372,47 @@ 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}") From 9e3ca2bb72169e9db1fc82bbe3d65289dde3d6a9 Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:23:51 +0200 Subject: [PATCH 15/27] feat(server): export explicit object member policies Declare Unicode case-alias rejection and closed-by-default unknown-field policy in the shared contract vocabulary. Regenerate the buffered Chat export and verify exported acceptance against the handwritten runtime under both trusted field policies. Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- .../server/experimental/contracts/README.md | 35 +++++++++++-------- .../contracts/guard-contract.schema.json | 6 ++++ .../contracts/minimal.guard.example.yaml | 11 +++++- .../chat-completions.buffered.guard.yaml | 13 +++++-- .../provider/projection_policy.py | 10 +++--- .../openai/chat_completions/README.md | 5 ++- tests/server/experimental/conftest.py | 7 ++-- .../experimental/test_guard_contract.py | 23 ++++++++++++ .../test_openai_chat_projections.py | 6 ++-- .../experimental/test_projection_policy.py | 28 +++++++++++++++ 10 files changed, 115 insertions(+), 29 deletions(-) diff --git a/nemoguardrails/server/experimental/contracts/README.md b/nemoguardrails/server/experimental/contracts/README.md index a25203021c..59bf4a3fb2 100644 --- a/nemoguardrails/server/experimental/contracts/README.md +++ b/nemoguardrails/server/experimental/contracts/README.md @@ -66,21 +66,26 @@ 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. +Legacy 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. +Legacy documents omitted the marker but followed the same case-alias convention. +`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/_generated/chat-completions.buffered.guard.yaml b/nemoguardrails/server/experimental/contracts/openai/_generated/chat-completions.buffered.guard.yaml index 67c8276373..66583de67f 100644 --- a/nemoguardrails/server/experimental/contracts/openai/_generated/chat-completions.buffered.guard.yaml +++ b/nemoguardrails/server/experimental/contracts/openai/_generated/chat-completions.buffered.guard.yaml @@ -49,7 +49,7 @@ request: classification: opaque messages: items: - additionalProperties: true + additionalProperties: false description: Accept one user text message with reviewed optional provider metadata. properties: @@ -82,6 +82,7 @@ request: x-nemo-guardrails: source: '#/components/schemas/ChatCompletionRequestUserMessage' unknown_fields: configurable + reject_case_aliases: true maxItems: 1 minItems: 1 type: array @@ -226,6 +227,8 @@ request: 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: @@ -234,7 +237,7 @@ response: properties: choices: items: - additionalProperties: true + additionalProperties: false description: Expose the guarded message while preserving opaque choice metadata. properties: finish_reason: @@ -253,7 +256,7 @@ response: gate: disabled reason: provider_integrity.token_logprobs message: - additionalProperties: true + additionalProperties: false description: Accept assistant text while preventing replacement of annotated content. properties: @@ -332,12 +335,14 @@ response: 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 @@ -375,6 +380,8 @@ response: 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 diff --git a/nemoguardrails/server/experimental/provider/projection_policy.py b/nemoguardrails/server/experimental/provider/projection_policy.py index bade5406e4..7881f35581 100644 --- a/nemoguardrails/server/experimental/provider/projection_policy.py +++ b/nemoguardrails/server/experimental/provider/projection_policy.py @@ -342,7 +342,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, @@ -369,9 +371,9 @@ def expand(node: dict[str, Any], active: tuple[str, ...] = ()) -> dict[str, Any] 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 + result["additionalProperties"] = False + metadata["unknown_fields"] = policy.unknown_fields + metadata["reject_case_aliases"] = True # Opaque values stay runtime extras, but the export lists them as # properties so additionalProperties describes only unreviewed members. properties = { diff --git a/nemoguardrails/server/experimental/providers/openai/chat_completions/README.md b/nemoguardrails/server/experimental/providers/openai/chat_completions/README.md index f5c0cb2df6..27eb8e7000 100644 --- a/nemoguardrails/server/experimental/providers/openai/chat_completions/README.md +++ b/nemoguardrails/server/experimental/providers/openai/chat_completions/README.md @@ -34,7 +34,10 @@ 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 [contract guide](../../../contracts/README.md) defines the document format; the [OpenAI boundary summary](../../../contracts/openai/README.md) records its 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_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_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..d76d880671 100644 --- a/tests/server/experimental/test_projection_policy.py +++ b/tests/server/experimental/test_projection_policy.py @@ -326,3 +326,31 @@ 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) From a81c72077f04597fed0c9d17d472d74a88da0c6c Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:37:28 +0200 Subject: [PATCH 16/27] fix(server): request identity encoding only when output is inspected The guarded Chat glue replaced Accept-Encoding with identity on every guarded request. The plain body is needed only when output checks run. Without them the response is relayed unchanged, so the client's compression preference can be kept. Move the header change into the buffered kernel, which knows the resolved inspection policy. It applies to any guarded operation, not just OpenAI Chat. The guarded Chat glue forwards the request as before. A still-encoded response under output inspection remains rejected. Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- .../server/experimental/_guarded_proxy.py | 18 +------- .../server/experimental/_http_kernel.py | 17 ++++++- .../openai/chat_completions/README.md | 8 ++-- tests/server/experimental/test_http_kernel.py | 46 +++++++++++++++++++ 4 files changed, 68 insertions(+), 21 deletions(-) diff --git a/nemoguardrails/server/experimental/_guarded_proxy.py b/nemoguardrails/server/experimental/_guarded_proxy.py index 96ea6e2b82..5434bfea5b 100644 --- a/nemoguardrails/server/experimental/_guarded_proxy.py +++ b/nemoguardrails/server/experimental/_guarded_proxy.py @@ -67,18 +67,6 @@ def _header(headers: tuple[tuple[bytes, bytes], ...], name: bytes) -> str | None return values[-1] if values else None -def _request_identity_encoding(headers: tuple[tuple[bytes, bytes], ...]) -> tuple[tuple[bytes, bytes], ...]: - """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. - """ - return ( - *((key, value) for key, value in headers if key.lower() != b"accept-encoding"), - (b"accept-encoding", b"identity"), - ) - - 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 @@ -159,11 +147,7 @@ def project_request(payload: GuardableProviderRequest) -> GuardedMessage: def forward_request(request: GuardableProviderRequest) -> BufferedHttpRequest: """Return the provider request that should be dispatched.""" prepared = _prepare_provider_request(request) - return replace( - request.request, - body=prepared.body, - headers=_request_identity_encoding(request.request.headers), - ) + return replace(request.request, body=prepared.body) def project_response( payload: BufferedHttpResponse, diff --git a/nemoguardrails/server/experimental/_http_kernel.py b/nemoguardrails/server/experimental/_http_kernel.py index 83d29b4fe6..508038e262 100644 --- a/nemoguardrails/server/experimental/_http_kernel.py +++ b/nemoguardrails/server/experimental/_http_kernel.py @@ -17,7 +17,7 @@ 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 @@ -401,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 @@ -437,6 +450,8 @@ async def bounded_dispatch(request: Any) -> BufferedHttpResponse: 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 diff --git a/nemoguardrails/server/experimental/providers/openai/chat_completions/README.md b/nemoguardrails/server/experimental/providers/openai/chat_completions/README.md index 27eb8e7000..566d5c632c 100644 --- a/nemoguardrails/server/experimental/providers/openai/chat_completions/README.md +++ b/nemoguardrails/server/experimental/providers/openai/chat_completions/README.md @@ -88,9 +88,11 @@ 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. -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. +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 diff --git a/tests/server/experimental/test_http_kernel.py b/tests/server/experimental/test_http_kernel.py index 76aec1e879..761768abbc 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): From eee516f0059917a9a05b382b4aa0ae568b7c554a Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:45:42 +0200 Subject: [PATCH 17/27] test(llm): isolate LLM call info in exception context tests TestRaiseLLMCallException fails on some CI orderings. The exception helper prefers model and provider names from llm_call_info_var. An earlier test on the same worker leaves that variable set to a fake model, so these tests read model=fake instead of their own test-model. Reset llm_call_info_var around each test in the class. This is test isolation only and does not change runtime behavior. Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- tests/test_http_error_handling.py | 8 ++++++++ 1 file changed, 8 insertions(+) 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()) From 9a034f542625572e554dc7a5dc3ff14db81b331a Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:01:56 +0200 Subject: [PATCH 18/27] fix(server): publish policy-aware request schemas Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- .../server/experimental/_guarded_proxy.py | 9 +- .../provider/projection_policy.py | 85 +++++++++++++------ .../openai/chat_completions/README.md | 4 + .../experimental/test_contract_export.py | 56 ++++++++++++ .../test_openai_chat_integration.py | 44 ++++++++++ 5 files changed, 172 insertions(+), 26 deletions(-) diff --git a/nemoguardrails/server/experimental/_guarded_proxy.py b/nemoguardrails/server/experimental/_guarded_proxy.py index 5434bfea5b..11267c4695 100644 --- a/nemoguardrails/server/experimental/_guarded_proxy.py +++ b/nemoguardrails/server/experimental/_guarded_proxy.py @@ -36,6 +36,7 @@ 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, JsonObject @@ -174,12 +175,18 @@ def project_response( except (InvalidJson, UnsupportedJsonShape) as error: raise UnsupportedGuardedPayload(str(error)) 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": endpoint.guarded_request_model.model_json_schema(), + "schema": request_schema, } }, } diff --git a/nemoguardrails/server/experimental/provider/projection_policy.py b/nemoguardrails/server/experimental/provider/projection_policy.py index 7881f35581..5ecb553ecd 100644 --- a/nemoguardrails/server/experimental/provider/projection_policy.py +++ b/nemoguardrails/server/experimental/provider/projection_policy.py @@ -330,6 +330,60 @@ 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. + """ + document = model.model_json_schema() + models = model_graph(model) + for node in (document, *document.get("$defs", {}).values()): + if "properties" not in node or node.get("title") not in models: + continue + policy = models[node["title"]].policy + metadata = dict(node.get(EXTENSION, {})) + if policy.source: + metadata["source"] = "#/components/schemas/" + policy.source + node["additionalProperties"] = False + metadata["unknown_fields"] = policy.unknown_fields + metadata["reject_case_aliases"] = True + properties = { + **node["properties"], + **{name: {EXTENSION: {"classification": "opaque"}} for name in policy.opaque}, + } + node["properties"] = dict(sorted(properties.items())) + node[EXTENSION] = metadata + 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: + 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. @@ -354,37 +408,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"] = False - metadata["unknown_fields"] = policy.unknown_fields - metadata["reject_case_aliases"] = True - # 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"}] @@ -397,7 +432,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 diff --git a/nemoguardrails/server/experimental/providers/openai/chat_completions/README.md b/nemoguardrails/server/experimental/providers/openai/chat_completions/README.md index 566d5c632c..3a8a34db18 100644 --- a/nemoguardrails/server/experimental/providers/openai/chat_completions/README.md +++ b/nemoguardrails/server/experimental/providers/openai/chat_completions/README.md @@ -39,6 +39,10 @@ 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. diff --git a/tests/server/experimental/test_contract_export.py b/tests/server/experimental/test_contract_export.py index 7903e0b260..c446d6e051 100644 --- a/tests/server/experimental/test_contract_export.py +++ b/tests/server/experimental/test_contract_export.py @@ -115,6 +115,62 @@ def test_export_follows_changed_endpoint_metadata(example_endpoint): 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 = example_endpoint.guarded_request_model.guarded_text_location + 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.""" diff --git a/tests/server/experimental/test_openai_chat_integration.py b/tests/server/experimental/test_openai_chat_integration.py index adfde6f613..bdd27dd96d 100644 --- a/tests/server/experimental/test_openai_chat_integration.py +++ b/tests/server/experimental/test_openai_chat_integration.py @@ -20,6 +20,8 @@ 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 ( @@ -30,6 +32,8 @@ ) from nemoguardrails.server.experimental._guarded_operation import UnsupportedGuardedPayload from nemoguardrails.server.experimental._http_kernel import BufferedHttpResponse +from nemoguardrails.server.experimental.provider.projection_policy import EXTENSION +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, @@ -501,6 +505,46 @@ def test_openai_error_preserves_provider_binding_failure_code(): assert b'"code":"unsupported_provider_api_revision"' in response.body +@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() From 3c6f2b897f8f3f89ac42350e84057bbbddb61de9 Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:02:08 +0200 Subject: [PATCH 19/27] fix(server): reject duplicate API revision selectors Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- .../server/experimental/provider/transport.py | 10 ++++--- .../test_provider_revision_binding.py | 26 +++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/nemoguardrails/server/experimental/provider/transport.py b/nemoguardrails/server/experimental/provider/transport.py index e8c1395964..0649bbe87f 100644 --- a/nemoguardrails/server/experimental/provider/transport.py +++ b/nemoguardrails/server/experimental/provider/transport.py @@ -139,12 +139,11 @@ def __post_init__(self) -> None: raise ValueError("Provider API revision binding values must not be empty.") def validate(self, request: ProviderTransportRequest) -> None: - """Validate a client-selected header or query revision.""" + """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] - received = values[-1] if values else None else: try: query = request.query.decode("ascii") @@ -154,7 +153,12 @@ def validate(self, request: ProviderTransportRequest) -> None: code=self.error_code, ) from error values = parse_qs(query, keep_blank_values=True).get(self.transport_name, []) - received = values[-1] if values else None + 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()}.", diff --git a/tests/server/experimental/test_provider_revision_binding.py b/tests/server/experimental/test_provider_revision_binding.py index 1f7ae70794..22ad187057 100644 --- a/tests/server/experimental/test_provider_revision_binding.py +++ b/tests/server/experimental/test_provider_revision_binding.py @@ -59,6 +59,32 @@ def test_provider_api_revision_binding_accepts_exact_header_or_query_revision(lo 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( From 64366b363ad9f7467eca09f334bc0e5c6f54a6d0 Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:02:18 +0200 Subject: [PATCH 20/27] docs(server): remove legacy guard contract wording Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- nemoguardrails/server/experimental/contracts/README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/nemoguardrails/server/experimental/contracts/README.md b/nemoguardrails/server/experimental/contracts/README.md index 59bf4a3fb2..98b84e823f 100644 --- a/nemoguardrails/server/experimental/contracts/README.md +++ b/nemoguardrails/server/experimental/contracts/README.md @@ -71,7 +71,7 @@ component. Canonical exports explicitly declare `unknown_fields: forbid` or `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. -Legacy objects without the marker are closed. Generic JSON Schema validators +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. @@ -83,7 +83,6 @@ This includes multi-character folds (`Straße` and `STRASSE`), the Kelvin sign 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. -Legacy documents omitted the marker but followed the same case-alias convention. `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. From 1a5aafb5c7ac548f11a6261f9fd5baa9752bd824 Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:16:33 +0200 Subject: [PATCH 21/27] fix(server): enforce object policy configuration invariants Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- .../experimental/provider/projection_policy.py | 2 ++ tests/server/experimental/test_projection_policy.py | 13 ++++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/nemoguardrails/server/experimental/provider/projection_policy.py b/nemoguardrails/server/experimental/provider/projection_policy.py index 5ecb553ecd..60e6346a32 100644 --- a/nemoguardrails/server/experimental/provider/projection_policy.py +++ b/nemoguardrails/server/experimental/provider/projection_policy.py @@ -101,6 +101,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() diff --git a/tests/server/experimental/test_projection_policy.py b/tests/server/experimental/test_projection_policy.py index d76d880671..b1e5decee4 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 ( @@ -54,6 +54,17 @@ 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")] + + def test_missing_policy_fails_at_class_definition(): with pytest.raises(ValueError, match="missing field policy"): From 32bd22c12d6925e17eb019f0cc1c70496fac4e44 Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:20:04 +0200 Subject: [PATCH 22/27] fix(server): bind schema policies to model identity Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- .../provider/projection_policy.py | 52 ++++++++++++------- .../experimental/test_projection_policy.py | 39 ++++++++++++++ 2 files changed, 73 insertions(+), 18 deletions(-) diff --git a/nemoguardrails/server/experimental/provider/projection_policy.py b/nemoguardrails/server/experimental/provider/projection_policy.py index 60e6346a32..38aa63f271 100644 --- a/nemoguardrails/server/experimental/provider/projection_policy.py +++ b/nemoguardrails/server/experimental/provider/projection_policy.py @@ -25,8 +25,10 @@ from dataclasses import dataclass from typing import Any, ClassVar, Literal, cast, get_args -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 ( @@ -119,6 +121,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. @@ -341,24 +368,8 @@ def policy_json_schema(model: type[PolicyModel]) -> dict[str, Any]: 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() - models = model_graph(model) - for node in (document, *document.get("$defs", {}).values()): - if "properties" not in node or node.get("title") not in models: - continue - policy = models[node["title"]].policy - metadata = dict(node.get(EXTENSION, {})) - if policy.source: - metadata["source"] = "#/components/schemas/" + policy.source - node["additionalProperties"] = False - metadata["unknown_fields"] = policy.unknown_fields - metadata["reject_case_aliases"] = True - properties = { - **node["properties"], - **{name: {EXTENSION: {"classification": "opaque"}} for name in policy.opaque}, - } - node["properties"] = dict(sorted(properties.items())) - node[EXTENSION] = metadata definitions = document.pop("$defs", {}) def inline(node: dict[str, Any], active: tuple[str, ...] = ()) -> dict[str, Any]: @@ -373,6 +384,11 @@ def inline(node: dict[str, Any], active: tuple[str, ...] = ()) -> dict[str, Any] 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) diff --git a/tests/server/experimental/test_projection_policy.py b/tests/server/experimental/test_projection_policy.py index b1e5decee4..87879ba088 100644 --- a/tests/server/experimental/test_projection_policy.py +++ b/tests/server/experimental/test_projection_policy.py @@ -29,6 +29,7 @@ export_payload_schema, guarded, payload_contract, + policy_json_schema, text_location, ) from nemoguardrails.server.experimental.provider.types import UnknownContentFieldPolicy @@ -65,6 +66,44 @@ class Invalid(PolicyModel): 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"): From 0c0b27140eab97739693b16dfb64f9082ba6e26b Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:23:37 +0200 Subject: [PATCH 23/27] refactor(server): derive text bindings from typed fields Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- .../provider/projection_policy.py | 105 ++++++++++++------ .../openai/chat_completions/README.md | 7 ++ .../experimental/test_projection_policy.py | 33 ++++++ 3 files changed, 108 insertions(+), 37 deletions(-) diff --git a/nemoguardrails/server/experimental/provider/projection_policy.py b/nemoguardrails/server/experimental/provider/projection_policy.py index 38aa63f271..11302db010 100644 --- a/nemoguardrails/server/experimental/provider/projection_policy.py +++ b/nemoguardrails/server/experimental/provider/projection_policy.py @@ -23,7 +23,8 @@ 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, GetJsonSchemaHandler, ValidationInfo, model_validator from pydantic.fields import FieldInfo @@ -462,56 +463,86 @@ def expand(node: dict[str, Any]) -> 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/providers/openai/chat_completions/README.md b/nemoguardrails/server/experimental/providers/openai/chat_completions/README.md index 3a8a34db18..d1a51bd5e4 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 diff --git a/tests/server/experimental/test_projection_policy.py b/tests/server/experimental/test_projection_policy.py index 87879ba088..15ce6cbf0e 100644 --- a/tests/server/experimental/test_projection_policy.py +++ b/tests/server/experimental/test_projection_policy.py @@ -208,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")] @@ -219,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")] From fdeb80b4171c9df4b222134a9847108e87d63fef Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:28:08 +0200 Subject: [PATCH 24/27] fix(server): validate exported policy against runtime bindings Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- .../experimental/provider/contract_export.py | 55 +++++++++++-- .../openai/chat_completions/README.md | 6 +- .../experimental/test_contract_export.py | 80 ++++++++++++++++++- 3 files changed, 132 insertions(+), 9 deletions(-) diff --git a/nemoguardrails/server/experimental/provider/contract_export.py b/nemoguardrails/server/experimental/provider/contract_export.py index 1cf3974726..3655227cfe 100644 --- a/nemoguardrails/server/experimental/provider/contract_export.py +++ b/nemoguardrails/server/experimental/provider/contract_export.py @@ -33,18 +33,58 @@ import json import sys from pathlib import Path -from typing import Any +from typing import Any, Literal, get_args, get_origin from nemoguardrails.server.experimental.provider.endpoint import GuardedJsonEndpoint -from nemoguardrails.server.experimental.provider.payload import validate_payload_projection_contract +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. @@ -60,8 +100,8 @@ def export_guard_contract(endpoint: GuardedJsonEndpoint) -> dict[str, Any]: Raises: TypeError: The endpoint or its models do not support policy export. - ValueError: Identity or projection metadata is invalid, or a payload - schema uses a construct unsupported by the shared schema exporter. + 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. @@ -72,8 +112,11 @@ def export_guard_contract(endpoint: GuardedJsonEndpoint) -> dict[str, Any]: 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_payload_projection_contract(request_model, "request") - response_contract = validate_payload_projection_contract(response_model, "response") + 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: diff --git a/nemoguardrails/server/experimental/providers/openai/chat_completions/README.md b/nemoguardrails/server/experimental/providers/openai/chat_completions/README.md index d1a51bd5e4..40be254617 100644 --- a/nemoguardrails/server/experimental/providers/openai/chat_completions/README.md +++ b/nemoguardrails/server/experimental/providers/openai/chat_completions/README.md @@ -57,7 +57,11 @@ 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: +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 diff --git a/tests/server/experimental/test_contract_export.py b/tests/server/experimental/test_contract_export.py index c446d6e051..601e426f84 100644 --- a/tests/server/experimental/test_contract_export.py +++ b/tests/server/experimental/test_contract_export.py @@ -28,11 +28,17 @@ 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 +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, ) @@ -96,6 +102,76 @@ def test_export_supports_another_endpoint_without_a_stream_selector(example_endp 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( @@ -132,7 +208,7 @@ def streams_response(self) -> bool: UnionRequest.projection_contract = payload_contract( UnionRequest, projection_id="example.request", direction="request" ) - UnionRequest.guarded_text_location = example_endpoint.guarded_request_model.guarded_text_location + UnionRequest.guarded_text_location = text_location(UnionRequest) endpoint = replace(example_endpoint, guarded_request_model=UnionRequest) with pytest.raises(ValueError, match="disjoint nullable"): From 097220a08a39b30bce285e69d0bae7ebacf99f27 Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:29:15 +0200 Subject: [PATCH 25/27] fix(server): honor endpoint rejection codes in buffered HTTP Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- .../server/experimental/_guarded_proxy.py | 24 ++++--- .../experimental/providers/openai/errors.py | 6 +- .../test_openai_chat_integration.py | 63 ++++++++++++++++++- 3 files changed, 82 insertions(+), 11 deletions(-) diff --git a/nemoguardrails/server/experimental/_guarded_proxy.py b/nemoguardrails/server/experimental/_guarded_proxy.py index 11267c4695..3f6a2a2a7a 100644 --- a/nemoguardrails/server/experimental/_guarded_proxy.py +++ b/nemoguardrails/server/experimental/_guarded_proxy.py @@ -104,14 +104,18 @@ def _prepare_guardable_request( except InvalidJson as error: raise InvalidGuardedPayload("The request body must be valid JSON.") from error except UnsupportedJsonShape as error: - raise UnsupportedGuardedPayload(str(error)) from 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")) from 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.") + raise UnsupportedGuardedPayload( + "The guarded endpoint does not support streaming responses yet.", endpoint.unsupported_request_code + ) return GuardableProviderRequest( request=request, raw_body=request.body, @@ -157,10 +161,14 @@ def project_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.") + 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.") + 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) @@ -171,9 +179,11 @@ def project_response( return ContentInspectionNotApplicable() return target.message except ValidationError as error: - raise UnsupportedGuardedPayload(guarded_schema_error(error, "successful response")) from error + raise UnsupportedGuardedPayload( + guarded_schema_error(error, "successful response"), endpoint.unsupported_response_code + ) from error except (InvalidJson, UnsupportedJsonShape) as error: - raise UnsupportedGuardedPayload(str(error)) from error + raise UnsupportedGuardedPayload(str(error), endpoint.unsupported_response_code) from error request_model = endpoint.guarded_request_model request_schema = ( diff --git a/nemoguardrails/server/experimental/providers/openai/errors.py b/nemoguardrails/server/experimental/providers/openai/errors.py index 3b483f4d30..e54621eed0 100644 --- a/nemoguardrails/server/experimental/providers/openai/errors.py +++ b/nemoguardrails/server/experimental/providers/openai/errors.py @@ -148,15 +148,15 @@ def render_openai_error( return _error_response(415, str(outcome.failure), "unsupported_request", outcome.failure.code) return _error_response( 422, - "The OpenAI Chat Completions request is outside the guarded profile.", + "The provider request is outside the guarded profile.", "unsupported_request", outcome.failure.code or "unsupported_chat_completions_shape", ) return _error_response( 502, - "The OpenAI Chat Completions response is outside the guarded profile.", + "The provider response is outside the guarded profile.", "unsupported_response", - "unsupported_chat_completions_response_shape", + outcome.failure.code or "unsupported_chat_completions_response_shape", ) diff --git a/tests/server/experimental/test_openai_chat_integration.py b/tests/server/experimental/test_openai_chat_integration.py index bdd27dd96d..63f0211ee9 100644 --- a/tests/server/experimental/test_openai_chat_integration.py +++ b/tests/server/experimental/test_openai_chat_integration.py @@ -14,6 +14,7 @@ # limitations under the License. import json +from dataclasses import replace import httpx import pytest @@ -31,7 +32,8 @@ ContentInspectionPolicy, ) from nemoguardrails.server.experimental._guarded_operation import UnsupportedGuardedPayload -from nemoguardrails.server.experimental._http_kernel import BufferedHttpResponse +from nemoguardrails.server.experimental._guarded_proxy import create_buffered_guarded_http_operation +from nemoguardrails.server.experimental._http_kernel import BufferedHttpResponse, create_http_proxy_router from nemoguardrails.server.experimental.provider.projection_policy import EXTENSION from nemoguardrails.server.experimental.providers.openai.chat_completions.endpoint import CHAT_COMPLETIONS_ENDPOINT from nemoguardrails.server.experimental.providers.openai.errors import ( @@ -505,6 +507,65 @@ def test_openai_error_preserves_provider_binding_failure_code(): 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"), [ From 5e3067270bfeff4a4c761009579dce26aec8619c Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:30:23 +0200 Subject: [PATCH 26/27] refactor(server): simplify buffered request forwarding Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- .../server/experimental/_guarded_proxy.py | 35 +++---------------- .../test_openai_chat_integration.py | 24 ++++++++++++- 2 files changed, 27 insertions(+), 32 deletions(-) diff --git a/nemoguardrails/server/experimental/_guarded_proxy.py b/nemoguardrails/server/experimental/_guarded_proxy.py index 3f6a2a2a7a..c5a774d52b 100644 --- a/nemoguardrails/server/experimental/_guarded_proxy.py +++ b/nemoguardrails/server/experimental/_guarded_proxy.py @@ -15,7 +15,7 @@ """Compose provider declarations with the guarded buffered HTTP pipeline.""" -from dataclasses import dataclass, replace +from dataclasses import dataclass from pydantic import ValidationError @@ -38,7 +38,7 @@ 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, JsonObject +from nemoguardrails.server.experimental.provider.types import GuardedMessage @dataclass(slots=True) @@ -46,20 +46,7 @@ class GuardableProviderRequest: """Hold one original HTTP request and its validated exact guarded target.""" request: BufferedHttpRequest - raw_body: bytes - payload: JsonObject target: GuardedMessageTarget - streaming: bool - - -@dataclass(frozen=True, slots=True) -class PreparedProviderRequest: - """Hold the exact provider request state after input inspection.""" - - body: bytes - input_message: GuardedMessage - body_modified: bool - streaming: bool def _header(headers: tuple[tuple[bytes, bytes], ...], name: bytes) -> str | None: @@ -118,20 +105,7 @@ def _prepare_guardable_request( ) return GuardableProviderRequest( request=request, - raw_body=request.body, - payload=payload, target=target, - streaming=False, - ) - - -def _prepare_provider_request(request: GuardableProviderRequest) -> PreparedProviderRequest: - """Preserve the original provider body after input inspection.""" - return PreparedProviderRequest( - body=request.raw_body, - input_message=request.target.message, - body_modified=False, - streaming=request.streaming, ) @@ -150,9 +124,8 @@ def project_request(payload: GuardableProviderRequest) -> GuardedMessage: return payload.target.message def forward_request(request: GuardableProviderRequest) -> BufferedHttpRequest: - """Return the provider request that should be dispatched.""" - prepared = _prepare_provider_request(request) - return replace(request.request, body=prepared.body) + """Forward the original HTTP request without reserializing its body.""" + return request.request def project_response( payload: BufferedHttpResponse, diff --git a/tests/server/experimental/test_openai_chat_integration.py b/tests/server/experimental/test_openai_chat_integration.py index 63f0211ee9..431495f260 100644 --- a/tests/server/experimental/test_openai_chat_integration.py +++ b/tests/server/experimental/test_openai_chat_integration.py @@ -33,7 +33,11 @@ ) 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 BufferedHttpResponse, create_http_proxy_router +from nemoguardrails.server.experimental._http_kernel import ( + BufferedHttpRequest, + BufferedHttpResponse, + create_http_proxy_router, +) from nemoguardrails.server.experimental.provider.projection_policy import EXTENSION from nemoguardrails.server.experimental.providers.openai.chat_completions.endpoint import CHAT_COMPLETIONS_ENDPOINT from nemoguardrails.server.experimental.providers.openai.errors import ( @@ -96,6 +100,24 @@ async def dispatch(_request): 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.""" From 472214721483b6ad0d71f6854c560ce51f582f55 Mon Sep 17 00:00:00 2001 From: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:54:39 +0200 Subject: [PATCH 27/27] test(server): cover buffered integration error paths Patch coverage reported about 51 changed lines without coverage. 24 of them were the contract export CLI, which the tests exercised only in subprocesses that coverage does not measure. The rest were endpoint, revision binding, adapter, and error rendering branches. Run the CLI in process for stdout, output, check, mismatch, and export failure, while keeping the subprocess tests for the module entry point. Cover endpoint route and binding declarations, revision binding values, the guarded operation's revision check and its OpenAPI parameter, paired request adapters, unique error status codes, HTTP failure rendering without exception text, and policy models used as mapping values. The lines left uncovered cannot be reached through the declarations: the profile-mismatch checks (one capability profile exists), a PolicyModel check that export performs first, the object-schema guard (PolicyModel cannot be a root model), and the __main__ entry line. Signed-off-by: Pouyanpi <13303554+Pouyanpi@users.noreply.github.com> --- .../experimental/test_contract_export.py | 44 +++++++++++++ tests/server/experimental/test_http_kernel.py | 11 ++++ .../test_openai_chat_contract_export.py | 39 ++++++++++++ .../test_openai_chat_integration.py | 62 +++++++++++++++++++ .../experimental/test_projection_policy.py | 13 ++++ .../test_provider_revision_binding.py | 17 +++++ 6 files changed, 186 insertions(+) diff --git a/tests/server/experimental/test_contract_export.py b/tests/server/experimental/test_contract_export.py index 601e426f84..451f71e337 100644 --- a/tests/server/experimental/test_contract_export.py +++ b/tests/server/experimental/test_contract_export.py @@ -345,3 +345,47 @@ def find_spec(self, fullname, path=None, target=None): """ 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_http_kernel.py b/tests/server/experimental/test_http_kernel.py index 761768abbc..5b1e5fc420 100644 --- a/tests/server/experimental/test_http_kernel.py +++ b/tests/server/experimental/test_http_kernel.py @@ -1462,3 +1462,14 @@ def test_guarded_route_matching_agrees_with_starlette(template): 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 index 546879293f..ef440e73f3 100644 --- a/tests/server/experimental/test_openai_chat_contract_export.py +++ b/tests/server/experimental/test_openai_chat_contract_export.py @@ -25,6 +25,7 @@ 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 @@ -150,3 +151,41 @@ def test_cli_rejects_wrong_endpoint_without_creating_an_artifact(tmp_path): 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 index 431495f260..62ff16be72 100644 --- a/tests/server/experimental/test_openai_chat_integration.py +++ b/tests/server/experimental/test_openai_chat_integration.py @@ -36,9 +36,17 @@ 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, @@ -715,3 +723,57 @@ async def dispatch(_request): 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_projection_policy.py b/tests/server/experimental/test_projection_policy.py index 15ce6cbf0e..d471ebd679 100644 --- a/tests/server/experimental/test_projection_policy.py +++ b/tests/server/experimental/test_projection_policy.py @@ -437,3 +437,16 @@ class Request(PolicyModel, GuardedPayloadModel): 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 index 22ad187057..0047db0967 100644 --- a/tests/server/experimental/test_provider_revision_binding.py +++ b/tests/server/experimental/test_provider_revision_binding.py @@ -127,3 +127,20 @@ def test_provider_api_revision_binding_rejects_path_location(): 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, + )