Skip to content
Closed
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ jobs:
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
python-version: '3.12'
- name: Install ext_proc and test dependencies
working-directory: kaito-guard-ext-proc
run: python -m pip install -e '.[dev]'
Expand Down
3 changes: 2 additions & 1 deletion docker/Dockerfile.kaito-guard-ext-proc
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
FROM python:3.11-slim
FROM python:3.12.13-slim-bookworm@sha256:4766d8b510c428e595d74b9cc5bbb2fae8e26316fffb4adc89908d79aacd58a2

WORKDIR /app

COPY kaito-guard-ext-proc/setup.py .
COPY kaito-guard-ext-proc/ext_proc/ ./ext_proc/
RUN pip install --no-cache-dir .
RUN python -c "from guardrail_core.streaming_window import StreamingBufferWindow, WindowScanner, WindowScanResult, WindowEmitResult, WindowScanError; from ext_proc.streaming_window import StreamingResponseWindow"

EXPOSE 9000

Expand Down
71 changes: 66 additions & 5 deletions kaito-guard-ext-proc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,17 @@
This package is the Envoy gateway/streaming adapter, not a guardrail
implementation. Guardrail logic belongs to the shared KAITO guardrail core,
as described in [kaito-project/production-stack#226](https://github.com/kaito-project/production-stack/issues/226).
Core integration is deferred until its dependency and API are pinned; no local
scanners or guard engine are provided here.
The response-window adapter uses KAITO's shared streaming window; no local
scanners, holdback algorithm, or guard engine are provided here.

The server registers Envoy's `envoy.service.ext_proc.v3.ExternalProcessor`
service and listens on `[::]:9000`. `Process` returns gRPC `UNIMPLEMENTED`
until the shared core is integrated. This skeleton does not inspect or protect
until the adapter is wired into the service. This skeleton does not inspect or protect
traffic and must not be used as an active guardrail.

## Run and test

Requires Python 3.11 or newer. From the repository root:
Requires Python 3.12 or newer, matching the shared core's minimum. From the repository root:

```sh
cd kaito-guard-ext-proc
Expand All @@ -28,10 +28,71 @@ python -m pytest
Runtime dependencies are defined only in `setup.py`. `xds-protos`, published
by the gRPC project, supplies the generated Envoy protobuf messages and service.

## Shared response window

`guardrail-core` is installed from the authoritative
[KAITO commit `dda1b10e881ed69228978964a2e89ecf241757f9`](https://github.com/kaito-project/kaito/commit/dda1b10e881ed69228978964a2e89ecf241757f9),
using its immutable source archive and `guardrail_core` subdirectory. It does not
follow `main`. The installed package exposes `StreamingBufferWindow`,
`WindowScanner`, `WindowScanResult`, `WindowEmitResult`, and `WindowScanError`
from `guardrail_core.streaming_window`.

[`StreamingResponseWindow`](ext_proc/streaming_window.py) only manages lifecycle.
Create one instance per response, supplying a scanner and a holdback length.
Holdback counts Python string Unicode characters, not UTF-8 bytes. Scanner
construction, policy ordering, and sufficient holdback remain caller responsibilities;
stateful scanners must also be scoped to the response.

```python
from guardrail_core.streaming_window import WindowScanner

from ext_proc.streaming_window import StreamingResponseWindow


def checked_chunks(scanner: WindowScanner, holdback_len: int, upstream_text):
response = StreamingResponseWindow(scanner, holdback_len=holdback_len)
try:
for text in upstream_text:
result = response.feed(text)
if result.blocked:
raise RuntimeError("Response blocked")
yield from result.chunks
result = response.finish()
if result.blocked:
raise RuntimeError("Response blocked")
yield from result.chunks
finally:
response.abort()
```

Only an upstream that verifies complete, successful termination may reach
`finish()`. Parse errors, truncation, and cancellation must instead call `abort()`
without flushing. An iterator that silently treats truncation as EOF is not a
suitable input. The example illustrates text-window usage, not an SSE parser
or Envoy output adapter.

- `feed()` and `finish()` return the shared `WindowEmitResult` unchanged.
- `blocked` and `redacted` expose shared-window state; `closed` is lifecycle state.
- A block result is terminal and must enter the caller's explicit rejection path.
- Scanner errors propagate as `WindowScanError` with their original cause;
exceptions and cancellation make the response terminal and must enter the
caller's failure path. Unchecked buffered text is never flushed on failure.
- `finish()` invokes the shared final `flush()` exactly once. Further `feed()`
or `finish()` calls after any terminal state raise `RuntimeError`.
- `abort()` is idempotent and never emits text. Discard the response instance
after termination. Neither this adapter nor its error messages log raw text.

This change does **not** integrate `Process`, migrate an SSE/OpenAI parser,
provide policy/scanner configuration, or establish Envoy end-to-end protection.
Tests validate the lifecycle adapter against direct calls to the pinned shared
window, not live gateway traffic.

## CI

The `kaito-guard-ext-proc` job in the repository's CI workflow installs
`.[dev]` with Python 3.11, runs pytest, and builds the container image.
`.[dev]` with Python 3.12, runs pytest, and builds the container image.
The image build also imports the shared window, its interfaces/results, and the
adapter to verify that the runtime installation contains them.
The image is not published, and this job does not test real Envoy integration.

## Container
Expand Down
66 changes: 66 additions & 0 deletions kaito-guard-ext-proc/ext_proc/streaming_window.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Copyright (c) KAITO authors.
# 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.

"""Response lifecycle only; buffering and safe release belong to KAITO core."""

from guardrail_core.streaming_window import (
StreamingBufferWindow,
WindowEmitResult,
WindowScanner,
)


class StreamingResponseWindow:
"""Own one shared window, with terminal finish, abort, block, and failure."""

def __init__(self, scanner: WindowScanner, *, holdback_len: int) -> None:
self._window = StreamingBufferWindow(scanner, holdback_len=holdback_len)
self._closed = False

@property
def closed(self) -> bool:
return self._closed

@property
def blocked(self) -> bool:
return self._window.blocked

@property
def redacted(self) -> bool:
return self._window.redacted

def feed(self, text: str) -> WindowEmitResult:
self._require_open()
try:
result = self._window.feed(text)
except BaseException:
# Cancellation must be terminal too; preserve the original failure.
self.abort()
raise
if result.blocked:
self.abort()
return result

def finish(self) -> WindowEmitResult:
"""Flush once, only after successful completion of the upstream stream."""
self._require_open()
self._closed = True
return self._window.flush()

def abort(self) -> None:
"""Terminate without scanning or releasing the retained tail."""
self._closed = True

def _require_open(self) -> None:
if self._closed:
raise RuntimeError("Streaming response window is closed.")
4 changes: 3 additions & 1 deletion kaito-guard-ext-proc/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
description="Python ext_proc for KAITO guardrailing in Envoy",
author="KAITO Team",
packages=find_packages(include=["ext_proc", "ext_proc.*"]),
python_requires=">=3.11",
python_requires=">=3.12",
install_requires=[
"guardrail-core @ https://github.com/kaito-project/kaito/archive/"
"dda1b10e881ed69228978964a2e89ecf241757f9.tar.gz#subdirectory=guardrail_core",
"grpcio==1.84.0",
"xds-protos==1.84.0",
],
Expand Down
Loading
Loading