Skip to content
Merged
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

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# LiteLLM Chat dialect

For Responses, see the [LiteLLM Responses dialect](responses.md), which has
its own supported boundary and compatibility evidence.

This experimental dialect applies the `single_text.v1` guardrail profile to
reviewed LiteLLM Chat Completions wire shapes. Typed Python declarations in
[chat_completions.py](chat_completions.py) and [stream.py](stream.py) are
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# LiteLLM Responses dialect

This experimental [dialect](responses.py) adds optional, opaque `model` metadata
at the root of SSE events. Requests, buffered responses, and stream lifecycle
checks use the [native Responses policy](../../responses/README.md).

## Supported boundary

Requests require one nonempty `input` string and explicit `store: false`.
Generation must be foreground; tools, conversation history, additional plaintext
instructions, and replacement are unsupported. Buffered and streaming output
support one assistant text response under the native content restrictions.

Reviewed response metadata, including `store` and `tool_usage`, remains opaque:
its values and nested structure are not inspected. The event's `model` field
does not select events or supply guarded text. Unknown root fields and events,
additional content channels, and nested LiteLLM SSE errors fail closed when
output inspection is enabled. Disabling output inspection retains raw response
relay; request restrictions still apply.

## Explicit selection

Select the endpoint when constructing the proxy integration:

```python
from dataclasses import replace

from nemoguardrails.server.experimental.providers.openai.integration import create_openai_integration
from nemoguardrails.server.experimental.providers.openai.dialects.litellm.responses import (
LITELLM_RESPONSES_ENDPOINT,
)

integration = replace(
create_openai_integration(base_url="https://your-gateway.example/v1"),
endpoints=(LITELLM_RESPONSES_ENDPOINT,),
)
```

Pass this integration to the proxy application with its Rails configuration.
The transparent HTTP proxy uses the `server` extra and the application's existing
upstream authorization; no LiteLLM SDK or additional dialect-specific key is
required. The gateway URL and request model do not select the dialect. Selecting
Responses alone leaves other routes on the application's unguarded passthrough
path; restrict exposed routes as described in the native policy.

## Compatibility evidence

The source baseline is LiteLLM `v1.105.1`, pinned to
[`d665dc1a6315847fcad6cc2c1264ceb47c06abce`](https://github.com/BerriAI/litellm/tree/d665dc1a6315847fcad6cc2c1264ceb47c06abce).
The reviewed sources are the
[Responses types](https://github.com/BerriAI/litellm/blob/d665dc1a6315847fcad6cc2c1264ceb47c06abce/litellm/types/llms/openai.py)
and [OpenAI response transformation](https://github.com/BerriAI/litellm/blob/d665dc1a6315847fcad6cc2c1264ceb47c06abce/litellm/llms/openai/responses/transformation.py).
A deployment's advertised version does not establish the absence of patches.

Two buffered/streaming cassettes in
`tests/recorded/server/experimental/openai/cassettes/test_litellm_responses/`
capture `openai/openai/gpt-5-nano` through NVIDIA's inference gateway with
`store: false`. They cover shared response metadata, the SSE `model` annotation,
text deltas, and repeated snapshots. This evidence is specific to that route;
Chat-to-Responses bridging and other LiteLLM backends require separate validation.
Other inherited events and rejection cases use synthetic unit fixtures.

The shared recording sanitizer retains content and nested item identifiers,
subject to credential filtering and metadata normalization. Replay compares
sanitized bytes, not original network chunk boundaries. Unsupported fields must
not be removed to make replay pass.

## Validation and export

```bash
NVIDIA_INFERENCE_KEY=... make record-cassettes \
RECORDED_TESTS=tests/recorded/server/experimental/openai/test_litellm_responses.py \
RECORDED_REQUIRED_KEYS=NVIDIA_INFERENCE_KEY

make replay-cassettes \
RECORDED_TESTS=tests/recorded/server/experimental/openai/test_litellm_responses.py

uv run --locked python -m nemoguardrails.server.experimental.provider.contract_export \
nemoguardrails.server.experimental.providers.openai.dialects.litellm.responses:LITELLM_RESPONSES_ENDPOINT \
--output nemoguardrails/server/experimental/contracts/openai/_generated/litellm-responses.guard.yaml
```

Capture saves the complete sanitized response; offline replay validates policy,
passthrough, and blocking with synthetic checker decisions. Replay is required
after a refresh and uses no live credentials. The exported YAML documents the
bound declarations and is not a runtime input; use `--check` instead of
`--output` to detect drift.
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Bind reviewed LiteLLM Responses metadata without relaxing text boundaries.

The event model annotation extends the native surface. Requests, buffered
responses, lifecycle response objects, and output-item policy remain OpenAI's.
See responses.md for source, recording provenance and explicit selection.
"""

from dataclasses import replace
from typing import Annotated, Any

from pydantic import BaseModel

from nemoguardrails.server.experimental.provider.projection_policy import (
PolicyModel,
opaque,
)
from nemoguardrails.server.experimental.provider.stream import StreamBinding
from nemoguardrails.server.experimental.provider.stream_classifier import StreamClassifierDefinition
from nemoguardrails.server.experimental.provider.stream_policy import (
build_policy_stream_classifier,
stream_shape_coverage,
stream_text_path,
)
from nemoguardrails.server.experimental.providers.openai.responses import stream_projection as models
from nemoguardrails.server.experimental.providers.openai.responses.endpoint import RESPONSES_ENDPOINT
from nemoguardrails.server.experimental.providers.openai.responses.stream_classifier import (
STREAM_CONTRACT as OPENAI_STREAM_CONTRACT,
)
from nemoguardrails.server.experimental.providers.openai.responses.stream_classifier import (
STREAM_NON_DATA_SHAPE,
STREAM_SENTINELS,
)
from nemoguardrails.server.experimental.providers.openai.responses.stream_classifier import (
STREAM_RULES as OPENAI_STREAM_RULES,
)
from nemoguardrails.server.experimental.providers.openai.responses.stream_hooks import ResponsesStreamHooks


class LiteLLMResponsesEventMetadata(PolicyModel):
"""Accept the gateway's model annotation without using it to select events."""

model: Annotated[Any, opaque()] = None


class LiteLLMTextDeltaEvent(models.ResponsesTextDeltaEventProjection, LiteLLMResponsesEventMetadata):
"""Keep the delta as the only newly generated text subject."""


class LiteLLMTextDoneEvent(models.ResponsesTextDoneEventProjection, LiteLLMResponsesEventMetadata):
"""Keep completed text subject to snapshot consistency checks."""


class LiteLLMPartAddedEvent(models.ResponsesContentPartAddedEventProjection, LiteLLMResponsesEventMetadata):
"""Retain the initial text part and its inherited content restrictions."""


class LiteLLMPartDoneEvent(models.ResponsesContentPartDoneEventProjection, LiteLLMResponsesEventMetadata):
"""Retain the completed text part as a checked snapshot."""


class LiteLLMMessageAddedEvent(models.ResponsesMessageAddedEventProjection, LiteLLMResponsesEventMetadata):
"""Preserve message identity and single-part cardinality."""


class LiteLLMMessageDoneEvent(models.ResponsesMessageDoneEventProjection, LiteLLMResponsesEventMetadata):
"""Preserve message completion checks and the original item identity."""


class LiteLLMReasoningAddedEvent(models.ResponsesReasoningAddedEventProjection, LiteLLMResponsesEventMetadata):
"""Permit reviewed reasoning metadata, never new plaintext reasoning."""


class LiteLLMReasoningDoneEvent(models.ResponsesReasoningDoneEventProjection, LiteLLMResponsesEventMetadata):
"""Keep reasoning summaries and content constrained to empty values."""


class LiteLLMCreatedEvent(models.ResponsesCreatedEventProjection, LiteLLMResponsesEventMetadata):
"""Check the creation snapshot with the reviewed response metadata."""


class LiteLLMInProgressEvent(models.ResponsesInProgressEventProjection, LiteLLMResponsesEventMetadata):
"""Check the progress snapshot without introducing a separate text target."""


class LiteLLMQueuedEvent(models.ResponsesQueuedEventProjection, LiteLLMResponsesEventMetadata):
"""Recognize queued metadata without enabling background requests."""


class LiteLLMCompletedEvent(models.ResponsesCompletedEventProjection, LiteLLMResponsesEventMetadata):
"""Check the native terminal snapshot against accumulated deltas."""


class LiteLLMFailedEvent(models.ResponsesFailedEventProjection, LiteLLMResponsesEventMetadata):
"""Keep any output in failed responses subject to snapshot checks."""


class LiteLLMIncompleteEvent(models.ResponsesIncompleteEventProjection, LiteLLMResponsesEventMetadata):
"""Keep partial output guarded even when generation is incomplete."""


class LiteLLMErrorEvent(models.ResponsesErrorEventProjection, LiteLLMResponsesEventMetadata):
"""Accept model metadata on the inherited flat diagnostic error shape."""


_EVENT_MODELS: dict[type[BaseModel], type[PolicyModel]] = {
models.ResponsesTextDeltaEventProjection: LiteLLMTextDeltaEvent,
models.ResponsesTextDoneEventProjection: LiteLLMTextDoneEvent,
models.ResponsesContentPartAddedEventProjection: LiteLLMPartAddedEvent,
models.ResponsesContentPartDoneEventProjection: LiteLLMPartDoneEvent,
models.ResponsesMessageAddedEventProjection: LiteLLMMessageAddedEvent,
models.ResponsesMessageDoneEventProjection: LiteLLMMessageDoneEvent,
models.ResponsesReasoningAddedEventProjection: LiteLLMReasoningAddedEvent,
models.ResponsesReasoningDoneEventProjection: LiteLLMReasoningDoneEvent,
models.ResponsesCreatedEventProjection: LiteLLMCreatedEvent,
models.ResponsesInProgressEventProjection: LiteLLMInProgressEvent,
models.ResponsesQueuedEventProjection: LiteLLMQueuedEvent,
models.ResponsesCompletedEventProjection: LiteLLMCompletedEvent,
models.ResponsesFailedEventProjection: LiteLLMFailedEvent,
models.ResponsesIncompleteEventProjection: LiteLLMIncompleteEvent,
models.ResponsesErrorEventProjection: LiteLLMErrorEvent,
}
STREAM_RULES = tuple(
replace(
rule,
model=_EVENT_MODELS[rule.model],
text_path=stream_text_path(_EVENT_MODELS[rule.model]) if rule.text_path is not None else None,
)
for rule in OPENAI_STREAM_RULES
)
STREAM_CONTRACT = replace(
OPENAI_STREAM_CONTRACT,
projection_id="openai.litellm.responses.stream.text.v1",
shapes=stream_shape_coverage(STREAM_RULES, sentinels=STREAM_SENTINELS, non_data_shape=STREAM_NON_DATA_SHAPE),
)
STREAM_CLASSIFIER = build_policy_stream_classifier(
StreamClassifierDefinition(
subject=STREAM_CONTRACT.projection_id,
contract=STREAM_CONTRACT,
rules=STREAM_RULES,
event_type_field="type",
sentinels=STREAM_SENTINELS,
non_data_shape=STREAM_NON_DATA_SHAPE,
)
)


LITELLM_RESPONSES_ENDPOINT = replace(
RESPONSES_ENDPOINT,
contract_name="litellm_responses",
operation="LiteLLM Responses",
stream=StreamBinding(STREAM_CLASSIFIER, ResponsesStreamHooks),
)
23 changes: 13 additions & 10 deletions tests/recorded/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,16 +128,19 @@ exchanges with synthetic checker decisions, not real Rails configuration.
Replayed-body equality is not a claim of original provider-wire byte fidelity
or live streaming delivery. Real-config and socket coverage is separate.

These six cassettes predate the dialect: their provider payloads are unchanged.
Two buffered successes contain message `provider_specific_fields: {refusal: null}`
and empty choice extensions. Two streaming successes contain no extension bags;
the other two responses are provider errors. The inventory tests validate every
successful payload against runtime declarations and export and pin those evidence
limits. The recordings do not establish the serving LiteLLM version or support
for arbitrary backend fields. Native finish metadata and empty stream containers
are covered by separately identified, source-backed unit fixtures, not invented
recordings. A refresh must review new fields rather than modifying payloads to
fit existing expectations.
The six Chat proxy cassettes contain two buffered successes with message
`provider_specific_fields: {refusal: null}` and empty choice extensions, two
streaming successes without extension bags, and two provider errors. Inventory
tests check successful payloads against runtime declarations and export.
Native finish metadata and empty stream containers have separate source-backed
unit fixtures. These recordings do not establish the serving LiteLLM version or
support for arbitrary backend fields; review new fields when refreshing them.

For LiteLLM Responses, refresh
`tests/recorded/server/experimental/openai/test_litellm_responses.py` with
`RECORDED_REQUIRED_KEYS=NVIDIA_INFERENCE_KEY`. The
[Responses dialect guide](../../nemoguardrails/server/experimental/providers/openai/dialects/litellm/responses.md)
documents the two cassettes, compatibility limits, and required offline replay.

`RECORDED_TESTS` is passed directly to pytest, so it can be a single test, a
test class, several files, or a directory:
Expand Down
Loading
Loading