Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 84 additions & 1 deletion nemoguardrails/server/experimental/_application.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@

from collections.abc import Callable, Sequence
from contextlib import asynccontextmanager
from typing import overload

import httpx
from fastapi import APIRouter, FastAPI

from nemoguardrails.server.experimental._configuration import ProxyConfig, resolve_config_selection, resolve_provider
from nemoguardrails.server.experimental._http_kernel import (
DEFAULT_MAX_REQUEST_BODY_BYTES,
DEFAULT_MAX_RESPONSE_BODY_BYTES,
Expand All @@ -38,6 +40,7 @@
from nemoguardrails.server.experimental.rails.config import validate_config_entries

HttpTransportFactory = Callable[[], httpx.AsyncBaseTransport]
_UNSET_CONFIG_RESOLUTION_TIMEOUT = object()


@asynccontextmanager
Expand Down Expand Up @@ -80,6 +83,17 @@ async def _lifespan(application: FastAPI):
del application.state.proxy_runtime


@overload
def create_proxy_application(
config: ProxyConfig,
*,
config_resolver: ConfigResolver | None = None,
local_routers: Sequence[APIRouter] = (),
http_transport_factory: HttpTransportFactory | None = None,
) -> FastAPI: ...


@overload
def create_proxy_application(
*,
providers: Sequence[ProviderIntegration],
Expand All @@ -92,8 +106,77 @@ def create_proxy_application(
max_request_body_bytes: int = DEFAULT_MAX_REQUEST_BODY_BYTES,
max_response_body_bytes: int = DEFAULT_MAX_RESPONSE_BODY_BYTES,
timeout: float = 30.0,
) -> FastAPI: ...


def create_proxy_application(
config: ProxyConfig | None = None,
*,
providers: Sequence[ProviderIntegration] | None = None,
rails_configs: Sequence[ConfigEntry] | None = None,
config_resolver: ConfigResolver | None = None,
config_resolution_timeout: object = _UNSET_CONFIG_RESOLUTION_TIMEOUT,
local_routers: Sequence[APIRouter] = (),
path_prefix: str | None = None,
http_transport_factory: HttpTransportFactory | None = None,
max_request_body_bytes: int | None = None,
max_response_body_bytes: int | None = None,
timeout: float | None = None,
) -> FastAPI:
"""Create one private proxy application with lifespan-owned HTTP dispatch."""
"""Create one proxy from a declaration or explicit Python integrations.

A ProxyConfig cannot be combined with direct assembly settings; callbacks
and local routers remain separate Python arguments. Declarative fixed Rails
IDs bypass the user resolver, while dynamic providers require it. Direct
calls retain their existing single-entry inference and explicit-resolver
behavior.

Configuration and route validation run before lifespan-owned HTTP and Rails
resources are created. No upstream calls are made during construction.
"""

if config is not None:
if not isinstance(config, ProxyConfig):
raise TypeError("config must be a ProxyConfig.")
if config_resolution_timeout is not _UNSET_CONFIG_RESOLUTION_TIMEOUT:
raise TypeError("ProxyConfig cannot be combined with direct assembly settings.")
if any(
value is not None
for value in (
providers,
rails_configs,
path_prefix,
max_request_body_bytes,
max_response_body_bytes,
timeout,
)
):
raise TypeError("ProxyConfig cannot be combined with direct assembly settings.")
config = ProxyConfig.model_validate(config)
config_resolver = resolve_config_selection(config, config_resolver)
providers = tuple(resolve_provider(provider) for provider in config.providers)
rails_configs = config.rails_configs
config_resolution_timeout = config.config_resolution_timeout
path_prefix = config.path_prefix
max_request_body_bytes = config.max_request_body_bytes
max_response_body_bytes = config.max_response_body_bytes
timeout = config.timeout
if providers is None:
raise TypeError("Supply a ProxyConfig or explicit providers.")
rails_configs = () if rails_configs is None else rails_configs
config_resolution_timeout = (
DEFAULT_CONFIG_RESOLUTION_TIMEOUT
if config_resolution_timeout is _UNSET_CONFIG_RESOLUTION_TIMEOUT
else config_resolution_timeout
)
path_prefix = "" if path_prefix is None else path_prefix
max_request_body_bytes = (
DEFAULT_MAX_REQUEST_BODY_BYTES if max_request_body_bytes is None else max_request_body_bytes
)
max_response_body_bytes = (
DEFAULT_MAX_RESPONSE_BODY_BYTES if max_response_body_bytes is None else max_response_body_bytes
)
timeout = 30.0 if timeout is None else timeout

if http_transport_factory is not None and not callable(http_transport_factory):
raise TypeError("The HTTP transport factory must be callable.")
Expand Down
179 changes: 179 additions & 0 deletions nemoguardrails/server/experimental/_configuration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
# 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.

"""Validate deployment declarations and resolve them to existing proxy bindings.

These models select reviewed Python policy; they neither load Rails directories
nor construct runtime owners. Provider URLs never select a dialect implicitly.
"""

from collections.abc import Awaitable
from dataclasses import replace
from typing import Literal

from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr, field_validator, model_validator
from typing_extensions import Self

from nemoguardrails.server.experimental._http_kernel import (
DEFAULT_MAX_REQUEST_BODY_BYTES,
DEFAULT_MAX_RESPONSE_BODY_BYTES,
)
from nemoguardrails.server.experimental._runtime import DEFAULT_CONFIG_RESOLUTION_TIMEOUT
from nemoguardrails.server.experimental.provider.endpoint import GuardedJsonEndpoint
from nemoguardrails.server.experimental.provider.integration import ProviderIntegration
from nemoguardrails.server.experimental.rails import ConfigContext, ConfigEntry, ConfigResolver


class ProviderConfig(BaseModel):
"""Select one built-in provider instance and its guarded operations.

An omitted dialect selects the provider's reviewed native bindings.
Operations install guard bindings, not an HTTP route allowlist. Other
routes retain passthrough behavior. A fixed Rails ID bypasses the user
resolver; otherwise the application must supply a trusted resolver.
"""

model_config = ConfigDict(extra="forbid", frozen=True, hide_input_in_errors=True, revalidate_instances="always")

provider: Literal["openai"]
instance_id: StrictStr
base_url: StrictStr = Field(repr=False)
operations: tuple[Literal["chat_completions", "responses"], ...] = Field(min_length=1)
mount_path: StrictStr = ""
dialect: Literal["litellm"] | None = None
rails_config_id: StrictStr | None = None

@field_validator("operations", mode="before")
@classmethod
def ordered_operations(cls, value: object) -> object:
"""Require an ordered concrete selection and reject coercible containers."""
if not isinstance(value, (list, tuple)):
raise ValueError("Operations must be a list or tuple of names.")
return value

@field_validator("operations")
@classmethod
def unique_operations(cls, value: tuple[str, ...]) -> tuple[str, ...]:
"""Reject duplicates instead of silently collapsing the declared policy."""
if len(value) != len(set(value)):
raise ValueError("Operations must not contain duplicate names.")
return value

@field_validator("rails_config_id")
@classmethod
def fixed_config_id(cls, value: str | None) -> str | None:
"""Accept an exact catalogue ID or an explicit dynamic selection."""
if value is not None and (not value or value != value.strip()):
raise ValueError("The fixed Rails config ID must be non-empty and trimmed.")
return value


class ProxyConfig(BaseModel):
"""Declare built-in instances and one shared Rails configuration catalogue.

Construction validates data without reading configuration files. Paths are
resolved by application assembly using the existing Python path semantics.
Callbacks are supplied to the application factory, not stored in this model.
"""

model_config = ConfigDict(extra="forbid", frozen=True, hide_input_in_errors=True, revalidate_instances="always")

providers: tuple[ProviderConfig, ...] = Field(min_length=1)
rails_configs: tuple[ConfigEntry, ...] = Field(min_length=1)
path_prefix: StrictStr = ""
max_request_body_bytes: StrictInt = Field(default=DEFAULT_MAX_REQUEST_BODY_BYTES, gt=0)
max_response_body_bytes: StrictInt = Field(default=DEFAULT_MAX_RESPONSE_BODY_BYTES, gt=0)
timeout: float = Field(default=30.0, strict=True, gt=0, allow_inf_nan=False)
config_resolution_timeout: float = Field(
default=DEFAULT_CONFIG_RESOLUTION_TIMEOUT, strict=True, gt=0, allow_inf_nan=False
)

@field_validator("providers", "rails_configs", mode="before")
@classmethod
def ordered_entries(cls, value: object) -> object:
"""Snapshot lists or tuples rather than consuming arbitrary iterables."""
if not isinstance(value, (list, tuple)):
raise ValueError("Configuration entries must be supplied as a list or tuple.")
return value

@model_validator(mode="after")
def config_references(self) -> Self:
"""Check catalogue identity and fixed references without filesystem I/O."""
ids = [entry.id for entry in self.rails_configs]
if any(not value or value != value.strip() for value in ids):
raise ValueError("Rails config IDs must be non-empty and trimmed.")
if len(ids) != len(set(ids)):
raise ValueError("Rails config IDs must be unique.")
for provider in self.providers:
if provider.rails_config_id is not None and provider.rails_config_id not in ids:
raise ValueError("A provider references an unknown Rails config ID.")
return self


def resolve_provider(config: ProviderConfig) -> ProviderIntegration:
"""Resolve the entire selection to reviewed bindings, without fallback."""
from nemoguardrails.server.experimental.providers.openai.chat_completions.endpoint import CHAT_COMPLETIONS_ENDPOINT
from nemoguardrails.server.experimental.providers.openai.dialects.litellm.chat_completions import (
LITELLM_CHAT_COMPLETIONS_ENDPOINT,
)
from nemoguardrails.server.experimental.providers.openai.dialects.litellm.responses import (
LITELLM_RESPONSES_ENDPOINT,
)
from nemoguardrails.server.experimental.providers.openai.integration import create_openai_integration
from nemoguardrails.server.experimental.providers.openai.responses.endpoint import RESPONSES_ENDPOINT

bindings: dict[tuple[str, str | None], dict[str, GuardedJsonEndpoint]] = {
("openai", None): {
"chat_completions": CHAT_COMPLETIONS_ENDPOINT,
"responses": RESPONSES_ENDPOINT,
},
("openai", "litellm"): {
"chat_completions": LITELLM_CHAT_COMPLETIONS_ENDPOINT,
"responses": LITELLM_RESPONSES_ENDPOINT,
},
}
try:
selected = bindings[(config.provider, config.dialect)]
endpoints = tuple(selected[name] for name in config.operations)
except KeyError:
raise ValueError("Unsupported provider, operation, and dialect combination.") from None
integration = create_openai_integration(
instance_id=config.instance_id, base_url=config.base_url, mount_path=config.mount_path
)
return replace(integration, endpoints=endpoints)


def resolve_config_selection(config: ProxyConfig, resolver: ConfigResolver | None) -> ConfigResolver:
"""Adapt explicit fixed/dynamic selection to the existing runtime resolver.

Fixed providers never call user code. Dynamic providers receive the original
context and may select any registered ID; authorization belongs to that
trusted callback. The existing runtime validates its result before dispatch.
"""
if resolver is not None and not callable(resolver):
raise TypeError("The config resolver must be callable.")
fixed = {provider.instance_id: provider.rails_config_id for provider in config.providers}
if resolver is None and any(value is None for value in fixed.values()):
raise ValueError("Providers without a fixed Rails config ID require a resolver.")

def select(context: ConfigContext) -> str | Awaitable[str]:
config_id = fixed[context.provider_instance_id]
if config_id is not None:
return config_id
if resolver is None:
raise ValueError("No resolver is configured for this provider.")
return resolver(context)

return select
2 changes: 1 addition & 1 deletion nemoguardrails/server/experimental/_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
_HTTP_SHUTDOWN_TIMEOUT_SECONDS = 5.0


def _validate_config_resolution_timeout(timeout: float) -> float:
def _validate_config_resolution_timeout(timeout: object) -> float:
if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or not math.isfinite(timeout) or timeout <= 0:
raise ValueError("The config resolution timeout must be a finite positive number of seconds.")
return float(timeout)
Expand Down
Loading
Loading