From bb200237bbd73d04be9c827e34d35a8e0e47ee49 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Tue, 2 Jun 2026 21:13:08 -0700 Subject: [PATCH] docs(examples): add opencode privacy-redaction demo --- examples/opencode-privacy-redaction/README.md | 102 +++++ .../policies/content-policy.yaml | 64 +++ .../policies/opencode-policy.yaml | 51 +++ .../proxy/.env.example | 35 ++ .../proxy/.gitignore | 14 + .../opencode-privacy-redaction/proxy/Makefile | 67 +++ .../proxy/pyproject.toml | 37 ++ .../proxy/src/redact_spike/__init__.py | 6 + .../proxy/src/redact_spike/config.py | 75 ++++ .../proxy/src/redact_spike/content_policy.py | 72 ++++ .../proxy/src/redact_spike/mcp_debug.py | 30 ++ .../proxy/src/redact_spike/mcp_server.py | 156 +++++++ .../src/redact_spike/private_switch_proxy.py | 22 + .../proxy/src/redact_spike/proxy.py | 363 ++++++++++++++++ .../proxy/src/redact_spike/redaction.py | 233 ++++++++++ .../proxy/src/redact_spike/upstream.py | 129 ++++++ .../proxy/tests/test_config.py | 37 ++ .../proxy/tests/test_content_policy.py | 52 +++ .../proxy/tests/test_mcp_server.py | 110 +++++ .../proxy/tests/test_proxy.py | 403 ++++++++++++++++++ .../proxy/tests/test_redaction.py | 141 ++++++ .../proxy/tests/test_upstream.py | 108 +++++ .../sandbox/opencode-provision.sh | 33 ++ .../sandbox/opencode.json | 38 ++ .../sandbox/setup-sandbox.sh | 52 +++ 25 files changed, 2430 insertions(+) create mode 100644 examples/opencode-privacy-redaction/README.md create mode 100644 examples/opencode-privacy-redaction/policies/content-policy.yaml create mode 100644 examples/opencode-privacy-redaction/policies/opencode-policy.yaml create mode 100644 examples/opencode-privacy-redaction/proxy/.env.example create mode 100644 examples/opencode-privacy-redaction/proxy/.gitignore create mode 100644 examples/opencode-privacy-redaction/proxy/Makefile create mode 100644 examples/opencode-privacy-redaction/proxy/pyproject.toml create mode 100644 examples/opencode-privacy-redaction/proxy/src/redact_spike/__init__.py create mode 100644 examples/opencode-privacy-redaction/proxy/src/redact_spike/config.py create mode 100644 examples/opencode-privacy-redaction/proxy/src/redact_spike/content_policy.py create mode 100644 examples/opencode-privacy-redaction/proxy/src/redact_spike/mcp_debug.py create mode 100644 examples/opencode-privacy-redaction/proxy/src/redact_spike/mcp_server.py create mode 100644 examples/opencode-privacy-redaction/proxy/src/redact_spike/private_switch_proxy.py create mode 100644 examples/opencode-privacy-redaction/proxy/src/redact_spike/proxy.py create mode 100644 examples/opencode-privacy-redaction/proxy/src/redact_spike/redaction.py create mode 100644 examples/opencode-privacy-redaction/proxy/src/redact_spike/upstream.py create mode 100644 examples/opencode-privacy-redaction/proxy/tests/test_config.py create mode 100644 examples/opencode-privacy-redaction/proxy/tests/test_content_policy.py create mode 100644 examples/opencode-privacy-redaction/proxy/tests/test_mcp_server.py create mode 100644 examples/opencode-privacy-redaction/proxy/tests/test_proxy.py create mode 100644 examples/opencode-privacy-redaction/proxy/tests/test_redaction.py create mode 100644 examples/opencode-privacy-redaction/proxy/tests/test_upstream.py create mode 100755 examples/opencode-privacy-redaction/sandbox/opencode-provision.sh create mode 100644 examples/opencode-privacy-redaction/sandbox/opencode.json create mode 100755 examples/opencode-privacy-redaction/sandbox/setup-sandbox.sh diff --git a/examples/opencode-privacy-redaction/README.md b/examples/opencode-privacy-redaction/README.md new file mode 100644 index 000000000..572a26c5a --- /dev/null +++ b/examples/opencode-privacy-redaction/README.md @@ -0,0 +1,102 @@ +# opencode + privacy-preserving image redaction + +Run [opencode](https://opencode.ai) inside an OpenShell sandbox where image payloads are kept away from the public inference provider and recovered, on demand, through a private local model. + +opencode talks to `inference.local`, which is routed to a host-side OpenAI-compatible **redaction proxy**. The proxy decides per request, from a content policy, whether to let an image through to the public model or to strip it and tell opencode to recover the data via a **local-model MCP server** running on the same host. + +``` +opencode (sandbox) + ├── chat ── inference.local ──▶ redaction proxy (host :8000) ──▶ public model (gpt-5.5) + │ │ if content_policy image: redact + │ └─ strips image, injects "use the local_model MCP" + └── tool ── local_model MCP (host :8001) ──▶ private/local model (e.g. LM Studio :1234) +``` + +The "demo magic": the proxy reads an OpenShell-style policy file (`policies/content-policy.yaml`) on **every** request. OpenShell itself does not interpret the `content_policy` block (that would need code changes) - only this proxy does. Flip one line and the next image request changes routing, no restart. + +## Layout + +| Path | What | +|------|------| +| `proxy/` | The redaction proxy + `call_inference` MCP server (a `uv` project). | +| `proxy/Makefile` | Start/stop the proxy and MCP; flip the demo policy. | +| `policies/opencode-policy.yaml` | Sandbox policy you give to OpenShell at create time. | +| `policies/content-policy.yaml` | Copy of the policy **plus** the `content_policy` block the proxy reads. | +| `sandbox/opencode.json` | opencode config: `inference.local` provider + `local_model` MCP. | +| `sandbox/opencode-provision.sh` | Writes the config + workspace inside the sandbox. | +| `sandbox/setup-sandbox.sh` | One command: create sandbox, provision, run web UI, expose it. | + +## Prerequisites + +- An OpenShell gateway registered and selected: `openshell gateway list` (the active one must be reachable as `host.openshell.internal` from sandboxes - this is the default for the local podman/VM gateway). +- A private/local OpenAI-compatible model for the MCP to call (the example defaults to LM Studio at `http://localhost:1234`). +- An `NVIDIA_API_KEY` for the public upstream (`https://inference-api.nvidia.com`). +- `uv` installed. + +## 1. Start the host services + +```shell +cd proxy +cp .env.example .env # then put your NVIDIA_API_KEY in .env +uv sync +make start # starts the MCP (:8001) and proxy (:8000) +``` + +The proxy and MCP bind to `0.0.0.0` so the sandbox can reach them via `host.openshell.internal`. + +## 2. One-time gateway wiring (inference.local -> the proxy) + +```shell +openshell provider create --name local-proxy --type openai \ + --credential OPENAI_API_KEY=dummy \ + --config OPENAI_BASE_URL=http://host.openshell.internal:8000/v1 + +openshell inference set --provider local-proxy --model openai/openai/gpt-5.5 --no-verify +``` + +`--no-verify` because host-side validation cannot resolve `host.openshell.internal`; runtime egress originates inside the sandbox and works. + +## 3. Create the opencode sandbox + +```shell +cd sandbox +./setup-sandbox.sh +``` + +This creates the `opencode` sandbox with `policies/opencode-policy.yaml`, writes `opencode.json`, starts `opencode web` on loopback inside the sandbox, and exposes it. Open the printed URL (e.g. `http://opencode--web.openshell.localhost:/`). + +Sanity check from the CLI: + +```shell +ssh -F /tmp/opencode-ssh-config openshell-opencode \ + 'export HOME=/sandbox; cd /sandbox/workspace; opencode run --model inference-local/openai/openai/gpt-5.5 "say hi"' +``` + +## 4. Demo: flip the content policy + +Default state ships as `redact`. Watch the proxy log while you drive opencode from the web UI: + +```shell +cd proxy && make tail-proxy-log +``` + +- **Allow** (`make demo-allow`): attach an image in opencode and ask about it. The proxy logs `image_redaction_skipped reason=allowed_by_policy` and the image reaches the public model. +- **Redact** (`make demo-redact`): re-send. The proxy logs `image_redaction_applied`, the image is replaced with the `redact_description`, and opencode calls the `local_model` MCP tool to recover the data from the private model. The image never reaches the public model. + +`make demo-status` shows the current rule. The proxy re-reads the file per request, so no restart is needed between toggles. + +Request/response captures land in `proxy/request-logs/` (gitignored) for inspection. + +## Tear down + +```shell +cd proxy && make stop +openshell service delete opencode web +openshell sandbox delete opencode +``` + +## Tests + +```shell +cd proxy && make test +``` diff --git a/examples/opencode-privacy-redaction/policies/content-policy.yaml b/examples/opencode-privacy-redaction/policies/content-policy.yaml new file mode 100644 index 000000000..876030c1f --- /dev/null +++ b/examples/opencode-privacy-redaction/policies/content-policy.yaml @@ -0,0 +1,64 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Demo "content policy" file. +# +# This is a COPY of opencode-policy.yaml with one extra top-level block: +# `content_policy`. The OpenShell policy engine does not interpret +# `content_policy` today (it would need code changes); only the redaction proxy +# in this example reads it. The proxy re-reads this file on EVERY request, so it +# is a live toggle: +# +# content_type.image: allow -> images pass through to the public model +# content_type.image: redact -> images are stripped before the public model, +# and `redact_description` is injected at the +# redaction site telling the model to recover +# the data via the local-model MCP tool. +# +# Flip the one line below and re-send an image from opencode to see the routing +# change with no restart. + +version: 1 + +filesystem_policy: + include_workdir: true + read_only: + - /usr + - /lib + - /proc + - /dev/urandom + - /app + - /etc + - /var/log + read_write: + - /sandbox + - /tmp + - /dev/null + - /home + - /root + - /dev/pts + +landlock: + compatibility: best_effort + +process: + run_as_user: sandbox + run_as_group: sandbox + +network_policies: + opencode_runtime: + name: opencode_runtime + endpoints: + - { host: host.openshell.internal, port: 8001 } + - { host: registry.npmjs.org, port: 443 } + - { host: "*.npmjs.org", port: 443 } + - { host: models.dev, port: 443 } + - { host: app.opencode.ai, port: 443 } + binaries: + - { path: "**" } + +# --- demo magic: interpreted by the redaction proxy, ignored by OpenShell --- +content_policy: + content_type: + image: redact + redact_description: Image has been redacted, use a local model through the `local_model` MCP server. diff --git a/examples/opencode-privacy-redaction/policies/opencode-policy.yaml b/examples/opencode-privacy-redaction/policies/opencode-policy.yaml new file mode 100644 index 000000000..81aaf00bf --- /dev/null +++ b/examples/opencode-privacy-redaction/policies/opencode-policy.yaml @@ -0,0 +1,51 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +version: 1 + +filesystem_policy: + include_workdir: true + read_only: + - /usr + - /lib + - /proc + - /dev/urandom + - /app + - /etc + - /var/log + read_write: + - /sandbox + - /tmp + - /dev/null + - /home + - /root + # PTY allocation for the opencode TUI / web terminal and bash tool. + # /dev/ptmx is a symlink into pts/ptmx, so granting the devpts mount covers it. + - /dev/pts + +landlock: + compatibility: best_effort + +process: + run_as_user: sandbox + run_as_group: sandbox + +network_policies: + # opencode runtime egress. inference.local is ALWAYS reachable regardless of + # network policy (the supervisor routes it), so it is intentionally absent here. + opencode_runtime: + name: opencode_runtime + endpoints: + # Redact MCP server, running on the host (same host as the inference proxy), + # reached from inside the sandbox via the host alias on its HTTP port. + - { host: host.openshell.internal, port: 8001 } + # opencode fetches the openai-compatible provider SDK (@ai-sdk/openai-compatible) + # and the models.dev metadata snapshot on first run. + - { host: registry.npmjs.org, port: 443 } + - { host: "*.npmjs.org", port: 443 } + - { host: models.dev, port: 443 } + # `opencode web` serves its SPA front-end from app.opencode.ai. Without this + # the local web server returns 403 (egress proxy denies the asset fetch). + - { host: app.opencode.ai, port: 443 } + binaries: + - { path: "**" } diff --git a/examples/opencode-privacy-redaction/proxy/.env.example b/examples/opencode-privacy-redaction/proxy/.env.example new file mode 100644 index 000000000..26822160b --- /dev/null +++ b/examples/opencode-privacy-redaction/proxy/.env.example @@ -0,0 +1,35 @@ +# Copy to .env and fill in NVIDIA_API_KEY. Loaded automatically by the proxy/MCP. +NVIDIA_API_KEY=your-nvapikey +ENV_FILE=.env + +# Public upstream the proxy forwards to when an image is NOT redacted. +# This is what inference.local resolves to from the sandbox. +UPSTREAM_BASE_URL=https://inference-api.nvidia.com +UPSTREAM_MODEL=openai/openai/gpt-5.5 + +# Private/local model the MCP `call_inference` tool calls (e.g. LM Studio). +PRIVATE_UPSTREAM_BASE_URL=http://localhost:1234 +PRIVATE_UPSTREAM_MODEL=nvidia/nemotron-3-nano-omni +PRIVATE_UPSTREAM_API_KEY= +MCP_FORCE_UPSTREAM_MODEL=true + +# Bind to 0.0.0.0 so the podman/VM-hosted sandbox can reach the host via +# host.openshell.internal. +PROXY_HOST=0.0.0.0 +PROXY_PORT=8000 +MCP_HOST=0.0.0.0 +MCP_PORT=8001 +MCP_TRANSPORT=http + +PROXY_LOG_LEVEL=INFO +REQUEST_LOG_DIR=request-logs + +# Demo magic: the proxy reads this OpenShell-style policy file on every request +# and redacts images only when content_policy.content_type.image == redact. +# (REDACTION_ENABLED is the fallback when CONTENT_POLICY_FILE is unset.) +CONTENT_POLICY_FILE=../policies/content-policy.yaml +REDACTION_ENABLED=false + +# Store redacted images so the MCP can re-load them via image_reference. +STORE_REDACTED_IMAGES=true +REDACTED_IMAGE_DIR=/tmp/redact-spike-images diff --git a/examples/opencode-privacy-redaction/proxy/.gitignore b/examples/opencode-privacy-redaction/proxy/.gitignore new file mode 100644 index 000000000..c442b9f73 --- /dev/null +++ b/examples/opencode-privacy-redaction/proxy/.gitignore @@ -0,0 +1,14 @@ +.venv/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.env +.env.* +!.env.example +uv.lock +redact-spike-images/ +request-logs/ +proxy.log +proxy.pid +mcp.log +mcp.pid diff --git a/examples/opencode-privacy-redaction/proxy/Makefile b/examples/opencode-privacy-redaction/proxy/Makefile new file mode 100644 index 000000000..4dd51feac --- /dev/null +++ b/examples/opencode-privacy-redaction/proxy/Makefile @@ -0,0 +1,67 @@ +ENV_FILE ?= $(if $(wildcard .env),.env,.env.example) +CONTENT_POLICY ?= ../policies/content-policy.yaml + +.PHONY: start-proxy stop-proxy restart-proxy tail-proxy-log \ + start-mcp stop-mcp restart-mcp tail-mcp-log \ + start stop demo-allow demo-redact demo-status test + +## --- proxy (OpenAI-compatible, inference.local upstream + image redaction) --- +start-proxy: + @if [ -f proxy.pid ] && kill -0 "$$(cat proxy.pid)" 2>/dev/null; then \ + echo "proxy already running: pid=$$(cat proxy.pid)"; \ + else \ + rm -f proxy.log proxy.pid; \ + ENV_FILE="$(ENV_FILE)" uv run redact-proxy > proxy.log 2>&1 & \ + echo $$! > proxy.pid; \ + echo "proxy started: pid=$$(cat proxy.pid), log=proxy.log, env=$(ENV_FILE)"; \ + fi + +stop-proxy: + @if [ -f proxy.pid ] && kill -0 "$$(cat proxy.pid)" 2>/dev/null; then \ + kill "$$(cat proxy.pid)"; echo "proxy stopped: pid=$$(cat proxy.pid)"; rm -f proxy.pid; \ + else echo "proxy not running"; rm -f proxy.pid; fi + +restart-proxy: stop-proxy start-proxy + +tail-proxy-log: + tail -f proxy.log + +## --- MCP (call_inference -> private/local model) --- +start-mcp: + @if [ -f mcp.pid ] && kill -0 "$$(cat mcp.pid)" 2>/dev/null; then \ + echo "mcp already running: pid=$$(cat mcp.pid)"; \ + else \ + rm -f mcp.log mcp.pid; \ + ENV_FILE="$(ENV_FILE)" uv run redact-mcp > mcp.log 2>&1 & \ + echo $$! > mcp.pid; \ + echo "mcp started: pid=$$(cat mcp.pid), log=mcp.log, env=$(ENV_FILE)"; \ + fi + +stop-mcp: + @if [ -f mcp.pid ] && kill -0 "$$(cat mcp.pid)" 2>/dev/null; then \ + kill "$$(cat mcp.pid)"; echo "mcp stopped: pid=$$(cat mcp.pid)"; rm -f mcp.pid; \ + else echo "mcp not running"; rm -f mcp.pid; fi + +restart-mcp: stop-mcp start-mcp + +tail-mcp-log: + tail -f mcp.log + +## --- convenience --- +start: start-mcp start-proxy +stop: stop-proxy stop-mcp + +## --- demo magic: flip the content policy live (proxy re-reads per request) --- +demo-allow: + @sed -i.bak -E 's/^( *image: *).*/\1allow/' "$(CONTENT_POLICY)" && rm -f "$(CONTENT_POLICY).bak" + @echo "content policy -> image: allow (images pass through to the public model)" + +demo-redact: + @sed -i.bak -E 's/^( *image: *).*/\1redact/' "$(CONTENT_POLICY)" && rm -f "$(CONTENT_POLICY).bak" + @echo "content policy -> image: redact (images stripped; routed via local-model MCP)" + +demo-status: + @grep -E '^ *image:' "$(CONTENT_POLICY)" || echo "no image rule found in $(CONTENT_POLICY)" + +test: + uv run pytest diff --git a/examples/opencode-privacy-redaction/proxy/pyproject.toml b/examples/opencode-privacy-redaction/proxy/pyproject.toml new file mode 100644 index 000000000..e6b1058ee --- /dev/null +++ b/examples/opencode-privacy-redaction/proxy/pyproject.toml @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[project] +name = "redact-spike" +version = "0.1.0" +description = "OpenAI-compatible inference proxy with policy-driven image redaction and MCP fallback." +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.115.0", + "fastmcp>=2.0.0", + "httpx>=0.28.0", + "python-dotenv>=1.0.0", + "pydantic>=2.10.0", + "pyyaml>=6.0", + "uvicorn>=0.32.0", +] + +[project.scripts] +redact-proxy = "redact_spike.proxy:main" +redact-private-proxy = "redact_spike.private_switch_proxy:main" +redact-mcp = "redact_spike.mcp_server:main" +redact-mcp-call = "redact_spike.mcp_debug:main" + +[dependency-groups] +dev = [ + "pytest>=8.3.0", + "pytest-asyncio>=0.24.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] diff --git a/examples/opencode-privacy-redaction/proxy/src/redact_spike/__init__.py b/examples/opencode-privacy-redaction/proxy/src/redact_spike/__init__.py new file mode 100644 index 000000000..2f97bda2b --- /dev/null +++ b/examples/opencode-privacy-redaction/proxy/src/redact_spike/__init__.py @@ -0,0 +1,6 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +__all__ = ["__version__"] + +__version__ = "0.1.0" diff --git a/examples/opencode-privacy-redaction/proxy/src/redact_spike/config.py b/examples/opencode-privacy-redaction/proxy/src/redact_spike/config.py new file mode 100644 index 000000000..bdd3dafef --- /dev/null +++ b/examples/opencode-privacy-redaction/proxy/src/redact_spike/config.py @@ -0,0 +1,75 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import os +import tempfile +from dataclasses import dataclass +from pathlib import Path + +from dotenv import load_dotenv + + +def _env_bool(name: str, default: bool) -> bool: + value = os.getenv(name) + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "on"} + + +@dataclass(frozen=True) +class Settings: + nvidia_api_key: str | None = None + upstream_base_url: str = "https://inference-api.nvidia.com" + upstream_model: str = "openai/openai/gpt-5.5" + private_upstream_base_url: str = "http://localhost:1234" + private_upstream_model: str = "qwen/qwen3.6-27b" + private_upstream_api_key: str | None = None + private_switch_force_model: bool = True + mcp_force_upstream_model: bool = True + mcp_transport: str = "http" + mcp_host: str = "127.0.0.1" + mcp_port: int = 8001 + proxy_host: str = "127.0.0.1" + proxy_port: int = 8000 + private_proxy_port: int = 8002 + proxy_log_level: str = "INFO" + request_log_dir: Path = Path("request-logs") + redaction_enabled: bool = True + store_redacted_images: bool = True + redacted_image_dir: Path = Path(tempfile.gettempdir()) / "redact-spike-images" + content_policy_file: Path | None = None + + @classmethod + def from_env(cls) -> "Settings": + _load_dotenv_file() + content_policy_file = os.getenv("CONTENT_POLICY_FILE") + return cls( + nvidia_api_key=os.getenv("NVIDIA_API_KEY"), + upstream_base_url=os.getenv("UPSTREAM_BASE_URL", cls.upstream_base_url), + upstream_model=os.getenv("UPSTREAM_MODEL", cls.upstream_model), + private_upstream_base_url=os.getenv("PRIVATE_UPSTREAM_BASE_URL", cls.private_upstream_base_url), + private_upstream_model=os.getenv("PRIVATE_UPSTREAM_MODEL", cls.private_upstream_model), + private_upstream_api_key=os.getenv("PRIVATE_UPSTREAM_API_KEY"), + private_switch_force_model=_env_bool("PRIVATE_SWITCH_FORCE_MODEL", cls.private_switch_force_model), + mcp_force_upstream_model=_env_bool("MCP_FORCE_UPSTREAM_MODEL", cls.mcp_force_upstream_model), + mcp_transport=os.getenv("MCP_TRANSPORT", cls.mcp_transport), + mcp_host=os.getenv("MCP_HOST", cls.mcp_host), + mcp_port=int(os.getenv("MCP_PORT", str(cls.mcp_port))), + proxy_host=os.getenv("PROXY_HOST", cls.proxy_host), + proxy_port=int(os.getenv("PROXY_PORT", str(cls.proxy_port))), + private_proxy_port=int(os.getenv("PRIVATE_PROXY_PORT", str(cls.private_proxy_port))), + proxy_log_level=os.getenv("PROXY_LOG_LEVEL", cls.proxy_log_level), + request_log_dir=Path(os.getenv("REQUEST_LOG_DIR", str(cls.request_log_dir))), + redaction_enabled=_env_bool("REDACTION_ENABLED", cls.redaction_enabled), + store_redacted_images=_env_bool("STORE_REDACTED_IMAGES", cls.store_redacted_images), + redacted_image_dir=Path(os.getenv("REDACTED_IMAGE_DIR", str(cls.redacted_image_dir))), + content_policy_file=Path(content_policy_file) if content_policy_file else None, + ) + + +def _load_dotenv_file() -> None: + env_file = Path(os.getenv("ENV_FILE", ".env")) + if env_file.exists(): + load_dotenv(env_file, override=True) diff --git a/examples/opencode-privacy-redaction/proxy/src/redact_spike/content_policy.py b/examples/opencode-privacy-redaction/proxy/src/redact_spike/content_policy.py new file mode 100644 index 000000000..b4a69d009 --- /dev/null +++ b/examples/opencode-privacy-redaction/proxy/src/redact_spike/content_policy.py @@ -0,0 +1,72 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from pathlib import Path + +import yaml + + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class ImageContentRule: + """Resolved `content_policy.content_type` decision for image payloads. + + `action` is "redact" or "allow". `description` is the operator-authored note + that the proxy substitutes at the redaction site (the `redact_description` + field), telling the main model how to recover the data via the MCP tool. + """ + + action: str + description: str | None = None + + @property + def should_redact(self) -> bool: + return self.action == "redact" + + +# Default when no content policy is in force: images pass through untouched. +ALLOW = ImageContentRule(action="allow") + + +def load_image_content_rule(path: Path | None) -> ImageContentRule | None: + """Read the image content rule from a policy file, re-read on every call. + + Returns None when no decision is expressed (no path, missing file, no + `content_policy.content_type.image` key) so the caller can fall back to its + own default. This is what makes the demo a live toggle: edit the file and + the next request observes the new rule, no restart required. + + The `content_policy` block is an OpenShell-style extension that the OpenShell + policy engine ignores today; only this proxy interprets it. + """ + if path is None: + return None + + if not path.exists(): + logger.info("content_policy_absent path=%s -> no rule", path) + return None + + try: + document = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except (OSError, yaml.YAMLError) as error: + logger.warning("content_policy_unreadable path=%s error=%s -> no rule", path, error) + return None + + content_type = (((document or {}).get("content_policy") or {}).get("content_type")) or {} + action = content_type.get("image") + if not isinstance(action, str): + return None + + description = content_type.get("redact_description") + rule = ImageContentRule( + action=action.strip().lower(), + description=description if isinstance(description, str) else None, + ) + logger.info("content_policy_loaded path=%s image_action=%s", path, rule.action) + return rule diff --git a/examples/opencode-privacy-redaction/proxy/src/redact_spike/mcp_debug.py b/examples/opencode-privacy-redaction/proxy/src/redact_spike/mcp_debug.py new file mode 100644 index 000000000..7cf5d708a --- /dev/null +++ b/examples/opencode-privacy-redaction/proxy/src/redact_spike/mcp_debug.py @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import argparse +import asyncio +import json +import sys +from typing import Any + +from redact_spike.mcp_server import call_inference_impl + + +async def _run(payload: dict[str, Any]) -> None: + response = await call_inference_impl(payload) + print(json.dumps(response, indent=2, sort_keys=True)) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Call the MCP inference implementation directly for debugging.") + parser.add_argument("--payload", help="OpenAI-style JSON payload. Reads stdin when omitted.") + args = parser.parse_args() + raw_payload = args.payload if args.payload is not None else sys.stdin.read() + payload = json.loads(raw_payload) + asyncio.run(_run(payload)) + + +if __name__ == "__main__": + main() diff --git a/examples/opencode-privacy-redaction/proxy/src/redact_spike/mcp_server.py b/examples/opencode-privacy-redaction/proxy/src/redact_spike/mcp_server.py new file mode 100644 index 000000000..f648a94c0 --- /dev/null +++ b/examples/opencode-privacy-redaction/proxy/src/redact_spike/mcp_server.py @@ -0,0 +1,156 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import copy +import datetime as dt +import json +import logging +import mimetypes +import base64 +import uuid +from pathlib import Path +from typing import Any + +from fastmcp import FastMCP + +from redact_spike.config import Settings +from redact_spike.upstream import NvidiaInferenceClient + + +logger = logging.getLogger(__name__) + + +async def call_inference_impl(payload: dict[str, Any], settings: Settings | None = None) -> dict[str, Any]: + settings = settings or Settings.from_env() + call_id = uuid.uuid4().hex[:8] + incoming_path = _write_mcp_capture(settings.request_log_dir, call_id, "mcp-incoming-request", payload) + outbound_payload = normalize_mcp_payload(payload, settings) + outbound_path = _write_mcp_capture(settings.request_log_dir, call_id, "mcp-normalized-request", outbound_payload) + logger.info( + "mcp_call_inference call_id=%s model=%s original_model=%s message_count=%s incoming=%s normalized=%s", + call_id, + outbound_payload.get("model"), + payload.get("model"), + len(outbound_payload.get("messages", [])), + incoming_path, + outbound_path, + ) + client = NvidiaInferenceClient( + settings, + base_url=settings.private_upstream_base_url, + model=settings.private_upstream_model, + api_key=settings.private_upstream_api_key, + require_api_key=False, + ) + response = await client.chat_completions(outbound_payload) + response_path = _write_mcp_capture( + settings.request_log_dir, + call_id, + "mcp-upstream-response", + {"status_code": response.status_code, "body": response.body}, + ) + logger.info("mcp_call_inference_response call_id=%s status=%s response=%s", call_id, response.status_code, response_path) + return {"status_code": response.status_code, "body": response.body} + + +def normalize_mcp_payload(payload: dict[str, Any], settings: Settings) -> dict[str, Any]: + normalized = copy.deepcopy(payload) + if settings.mcp_force_upstream_model or not normalized.get("model"): + normalized["model"] = settings.private_upstream_model + + messages = normalized.get("messages") + if isinstance(messages, list): + normalized["messages"] = [_normalize_message(message) for message in messages] + return normalized + + +def _normalize_message(message: Any) -> Any: + if not isinstance(message, dict): + return message + + normalized = dict(message) + content = normalized.get("content") + if isinstance(content, list): + normalized["content"] = [_normalize_content_part(part) for part in content] + return normalized + + +def _normalize_content_part(part: Any) -> Any: + if isinstance(part, str): + return {"type": "text", "text": part} + if not isinstance(part, dict): + return {"type": "text", "text": f"Unsupported content part: {json.dumps(part, sort_keys=True)}"} + + part_type = part.get("type") + if part_type in {"text", "image_url", "input_image"}: + return part + if part_type == "image_reference": + return _image_reference_to_image_url(part) + if part_type == "image_placeholder": + placeholder_id = str(part.get("id", "UNKNOWN_IMAGE")) + return { + "type": "text", + "text": ( + f"Image placeholder [{placeholder_id}] was referenced, but no image bytes or local image reference were " + "provided to this MCP call. Retry with a text part plus an image_reference part using the local path " + "from the redaction context." + ), + } + return {"type": "text", "text": f"Unsupported content part: {json.dumps(part, sort_keys=True)}"} + + +def _image_reference_to_image_url(part: dict[str, Any]) -> dict[str, Any]: + reference = part.get("path") or part.get("reference") or part.get("url") + if not isinstance(reference, str): + return {"type": "text", "text": "Invalid image_reference part: missing path, reference, or url."} + + path = Path(reference.removeprefix("file://")) + if not path.exists() or not path.is_file(): + return {"type": "text", "text": f"Invalid image_reference part: file does not exist at {reference}."} + + mime_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream" + encoded = base64.b64encode(path.read_bytes()).decode("ascii") + return {"type": "image_url", "image_url": {"url": f"data:{mime_type};base64,{encoded}"}} + + +def create_mcp_server(settings: Settings | None = None) -> FastMCP: + mcp = FastMCP("redacted-inference") + + @mcp.tool + async def call_inference(payload: dict[str, Any]) -> dict[str, Any]: + """Call the configured OpenAI-compatible inference endpoint. + + Provide an OpenAI-style payload. The server will use the configured model, so do not invent model names. + If an image was redacted, include a user message whose content has both a text part and an image part. + Use {"type":"image_reference","path":""} when the redaction context provides a local image path. + A placeholder such as REDACTED_IMAGE_1 alone is only an identifier, not image data. + """ + return await call_inference_impl(payload, settings=settings) + + return mcp + + +mcp = create_mcp_server() + + +def main() -> None: + settings = Settings.from_env() + logging.basicConfig(level=settings.proxy_log_level.upper()) + if settings.mcp_transport == "stdio": + mcp.run() + else: + mcp.run(transport=settings.mcp_transport, host=settings.mcp_host, port=settings.mcp_port) + + +def _write_mcp_capture(log_dir: Path, call_id: str, label: str, payload: Any) -> str: + log_dir.mkdir(parents=True, exist_ok=True) + timestamp = dt.datetime.now(dt.UTC).strftime("%Y%m%dT%H%M%S.%fZ") + path = log_dir / f"{timestamp}-{call_id}-{label}.json" + path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") + return str(path) + + +if __name__ == "__main__": + main() diff --git a/examples/opencode-privacy-redaction/proxy/src/redact_spike/private_switch_proxy.py b/examples/opencode-privacy-redaction/proxy/src/redact_spike/private_switch_proxy.py new file mode 100644 index 000000000..0e7a5750a --- /dev/null +++ b/examples/opencode-privacy-redaction/proxy/src/redact_spike/private_switch_proxy.py @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import uvicorn + +from redact_spike.config import Settings +from redact_spike.proxy import create_app + + +app = create_app(private_model_switch=True) + + +def main() -> None: + settings = Settings.from_env() + uvicorn.run( + "redact_spike.private_switch_proxy:app", + host=settings.proxy_host, + port=settings.private_proxy_port, + reload=False, + ) diff --git a/examples/opencode-privacy-redaction/proxy/src/redact_spike/proxy.py b/examples/opencode-privacy-redaction/proxy/src/redact_spike/proxy.py new file mode 100644 index 000000000..03328188e --- /dev/null +++ b/examples/opencode-privacy-redaction/proxy/src/redact_spike/proxy.py @@ -0,0 +1,363 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import datetime as dt +import json +import logging +import re +import time +import uuid +from collections.abc import AsyncIterator +from pathlib import Path +from typing import Any, Protocol + +import uvicorn +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import JSONResponse, StreamingResponse + +from redact_spike.config import Settings +from redact_spike.content_policy import load_image_content_rule +from redact_spike.redaction import DiskImageStore, last_message_has_image, redact_images, summarize_payload +from redact_spike.upstream import NvidiaInferenceClient, UpstreamResponse + + +class ChatCompletionsClient(Protocol): + async def chat_completions(self, payload: dict[str, Any]) -> UpstreamResponse: + pass + + async def stream_chat_completions(self, payload: dict[str, Any]) -> AsyncIterator[bytes]: + pass + + +logger = logging.getLogger(__name__) + + +def create_app( + settings: Settings | None = None, + upstream_client: ChatCompletionsClient | None = None, + private_upstream_client: ChatCompletionsClient | None = None, + private_model_switch: bool = False, +) -> FastAPI: + settings = settings or Settings.from_env() + logging.basicConfig(level=settings.proxy_log_level.upper()) + + app = FastAPI(title="Redacted Inference Proxy", version="0.1.0") + app.state.settings = settings + app.state.upstream_client = upstream_client or NvidiaInferenceClient(settings) + app.state.private_upstream_client = private_upstream_client or NvidiaInferenceClient( + settings, + base_url=settings.private_upstream_base_url, + model=settings.private_upstream_model, + api_key=settings.private_upstream_api_key, + require_api_key=False, + ) + + @app.middleware("http") + async def log_request(request: Request, call_next): + started_at = time.perf_counter() + request_id = uuid.uuid4().hex[:8] + request.state.request_id = request_id + headers = _sanitized_headers(request) + body = await request.body() + request_log_path = _write_request_file(settings.request_log_dir, request, headers, body, request_id) + logger.info( + "request_started request_id=%s method=%s path=%s query=%s headers=%s request_log=%s", + request_id, + request.method, + request.url.path, + request.url.query, + headers, + request_log_path, + ) + response = await call_next(request) + duration_ms = round((time.perf_counter() - started_at) * 1000, 2) + logger.info( + "request_finished request_id=%s method=%s path=%s status=%s duration_ms=%s", + request_id, + request.method, + request.url.path, + response.status_code, + duration_ms, + ) + return response + + @app.get("/health") + async def health() -> dict[str, str]: + return {"status": "ok"} + + @app.get("/v1/models") + @app.get("/models") + async def models() -> dict[str, Any]: + return { + "object": "list", + "data": [ + { + "id": settings.upstream_model, + "object": "model", + "owned_by": "nvidia", + } + ], + } + + @app.post("/v1/chat/completions") + @app.post("/v1/chat/completion") + @app.post("/chat/completions") + @app.post("/chat/completion") + async def chat_completions(request: Request) -> JSONResponse: + payload = await request.json() + if not isinstance(payload, dict): + raise HTTPException(status_code=400, detail="Expected JSON object request body") + + payload_summary = summarize_payload(payload) + logger.info( + "chat_completion_received model=%s message_count=%s has_image=%s content_part_types=%s", + payload_summary["model"], + payload_summary["message_count"], + payload_summary["has_image"], + payload_summary["content_part_types"], + ) + + outbound_payload, use_private_upstream = _build_outbound_payload(payload, settings, private_model_switch) + + outbound_log_path = _write_json_capture_file( + settings.request_log_dir, + request.state.request_id, + "outbound-redacted-request", + outbound_payload, + ) + logger.info("redacted_request_captured request_id=%s path=%s", request.state.request_id, outbound_log_path) + + if outbound_payload.get("stream") is True: + logger.info("calling_upstream stream=true private_upstream=%s", use_private_upstream) + upstream_client = app.state.private_upstream_client if use_private_upstream else app.state.upstream_client + chunks = upstream_client.stream_chat_completions(outbound_payload) + if use_private_upstream: + chunks = _normalize_reasoning_stream(chunks) + stream = _capture_streaming_response( + chunks, + settings.request_log_dir, + request.state.request_id, + ) + return StreamingResponse( + stream, + media_type="text/event-stream", + ) + + logger.info("calling_upstream stream=false private_upstream=%s", use_private_upstream) + upstream_client = app.state.private_upstream_client if use_private_upstream else app.state.upstream_client + response = await upstream_client.chat_completions(outbound_payload) + response_body = _normalize_reasoning_response(response.body) if use_private_upstream else response.body + response_log_path = _write_json_capture_file( + settings.request_log_dir, + request.state.request_id, + "upstream-response", + {"status_code": response.status_code, "body": response_body}, + ) + logger.info("upstream_response_captured request_id=%s path=%s", request.state.request_id, response_log_path) + return JSONResponse(status_code=response.status_code, content=response_body) + + return app + + +app = create_app() + + +def main() -> None: + settings = Settings.from_env() + uvicorn.run("redact_spike.proxy:app", host=settings.proxy_host, port=settings.proxy_port, reload=False) + + +def _build_outbound_payload( + payload: dict[str, Any], + settings: Settings, + private_model_switch: bool, +) -> tuple[dict[str, Any], bool]: + if private_model_switch and last_message_has_image(payload): + outbound_payload = dict(payload) + if settings.private_switch_force_model: + outbound_payload["model"] = settings.private_upstream_model + logger.info( + "private_model_switch_applied private_base_url=%s private_model=%s force_model=%s", + settings.private_upstream_base_url, + outbound_payload.get("model"), + settings.private_switch_force_model, + ) + return outbound_payload, True + + if private_model_switch: + logger.info("private_model_switch_skipped reason=last_message_has_no_image") + + should_redact, redaction_context = _resolve_image_redaction(settings) + + if should_redact: + replacement_style = "short" if private_model_switch else "mcp" + logger.info( + "image_redaction_started store_redacted_images=%s replacement_style=%s custom_context=%s", + settings.store_redacted_images, + replacement_style, + redaction_context is not None, + ) + store = DiskImageStore(settings.redacted_image_dir) if settings.store_redacted_images and not private_model_switch else None + redaction_result = redact_images( + payload, store=store, replacement_style=replacement_style, redaction_context=redaction_context + ) + if redaction_result.images: + logger.info( + "image_redaction_applied count=%s placeholders=%s references=%s", + len(redaction_result.images), + [image.placeholder for image in redaction_result.images], + [image.reference for image in redaction_result.images if image.reference], + ) + else: + logger.info("image_redaction_skipped reason=no_images_found") + return redaction_result.payload, False + + logger.info("image_redaction_skipped reason=allowed_by_policy") + return payload, False + + +def _resolve_image_redaction(settings: Settings) -> tuple[bool, str | None]: + """Decide whether to redact images on this request. + + When a `CONTENT_POLICY_FILE` is configured, the file is the source of truth + and is re-read per request (the live demo toggle). Its + `content_policy.content_type.image` value (`redact`/`allow`) decides, and + `redact_description` overrides the note injected at the redaction site. + With no content-policy decision, fall back to the static `REDACTION_ENABLED`. + """ + rule = load_image_content_rule(settings.content_policy_file) + if rule is not None: + return rule.should_redact, rule.description + return settings.redaction_enabled, None + + +def _sanitized_headers(request: Request) -> dict[str, str]: + allowed_headers = {"accept", "content-type", "user-agent", "host"} + secret_headers = {"authorization", "x-api-key", "api-key"} + headers: dict[str, str] = {} + for key, value in request.headers.items(): + lower_key = key.lower() + if lower_key in secret_headers: + headers[lower_key] = "[REDACTED]" + elif lower_key in allowed_headers: + headers[lower_key] = value + return headers + + +def _write_request_file(log_dir: Path, request: Request, headers: dict[str, str], body: bytes, request_id: str) -> str: + log_dir.mkdir(parents=True, exist_ok=True) + timestamp = dt.datetime.now(dt.UTC).strftime("%Y%m%dT%H%M%S.%fZ") + safe_path = re.sub(r"[^a-zA-Z0-9._-]+", "-", request.url.path.strip("/") or "root") + path = log_dir / f"{timestamp}-{request_id}-incoming-{request.method.lower()}-{safe_path}.json" + + content_type = request.headers.get("content-type", "") + decoded_body = body.decode("utf-8", errors="replace") + record: dict[str, Any] = { + "method": request.method, + "url": str(request.url), + "path": request.url.path, + "query": request.url.query, + "headers": headers, + "content_type": content_type, + "body_bytes": len(body), + "body_text": decoded_body, + } + + if "application/json" in content_type.lower() and body: + try: + record["body_json"] = json.loads(body) + except json.JSONDecodeError: + record["body_json_parse_error"] = "invalid_json" + + path.write_text(json.dumps(record, indent=2, sort_keys=True), encoding="utf-8") + return str(path) + + +def _write_json_capture_file(log_dir: Path, request_id: str, label: str, payload: Any) -> str: + log_dir.mkdir(parents=True, exist_ok=True) + timestamp = dt.datetime.now(dt.UTC).strftime("%Y%m%dT%H%M%S.%fZ") + path = log_dir / f"{timestamp}-{request_id}-{label}.json" + path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") + return str(path) + + +async def _capture_streaming_response( + chunks: AsyncIterator[bytes], + log_dir: Path, + request_id: str, +) -> AsyncIterator[bytes]: + captured = bytearray() + try: + async for chunk in chunks: + captured.extend(chunk) + yield chunk + finally: + path = _write_json_capture_file( + log_dir, + request_id, + "upstream-stream-response", + { + "body_text": captured.decode("utf-8", errors="replace"), + "body_bytes": len(captured), + }, + ) + logger.info("upstream_response_captured request_id=%s path=%s", request_id, path) + + +async def _normalize_reasoning_stream(chunks: AsyncIterator[bytes]) -> AsyncIterator[bytes]: + buffer = "" + async for chunk in chunks: + buffer += chunk.decode("utf-8", errors="replace") + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) + yield (_normalize_sse_line(line) + "\n").encode("utf-8") + if buffer: + yield _normalize_sse_line(buffer).encode("utf-8") + + +def _normalize_sse_line(line: str) -> str: + if not line.startswith("data: "): + return line + + data = line.removeprefix("data: ") + if data.strip() == "[DONE]": + return line + + try: + payload = json.loads(data) + except json.JSONDecodeError: + return line + + return "data: " + json.dumps(_normalize_reasoning_response(payload), separators=(",", ":")) + + +def _normalize_reasoning_response(payload: Any) -> Any: + if not isinstance(payload, dict): + return payload + + choices = payload.get("choices") + if not isinstance(choices, list): + return payload + + for choice in choices: + if not isinstance(choice, dict): + continue + for key in ("message", "delta"): + message = choice.get(key) + if isinstance(message, dict): + _copy_reasoning_to_provider_specific_fields(message) + return payload + + +def _copy_reasoning_to_provider_specific_fields(message: dict[str, Any]) -> None: + reasoning_content = message.get("reasoning_content") + if reasoning_content is None: + return + + provider_specific_fields = message.get("provider_specific_fields") + if not isinstance(provider_specific_fields, dict): + provider_specific_fields = {} + message["provider_specific_fields"] = provider_specific_fields + provider_specific_fields.setdefault("reasoning_content", reasoning_content) diff --git a/examples/opencode-privacy-redaction/proxy/src/redact_spike/redaction.py b/examples/opencode-privacy-redaction/proxy/src/redact_spike/redaction.py new file mode 100644 index 000000000..ec07765e4 --- /dev/null +++ b/examples/opencode-privacy-redaction/proxy/src/redact_spike/redaction.py @@ -0,0 +1,233 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import base64 +import copy +import hashlib +import json +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +DATA_URL_RE = re.compile(r"^data:(?Pimage/[a-zA-Z0-9.+-]+);base64,(?P.+)$", re.DOTALL) +IMAGE_KEYWORDS = {"image", "image_url", "input_image"} +REDACTION_CONTEXT = ( + "This image was redacted before this request was sent to you. " + "If the user's task requires information from a redacted image, use the pre-installed MCP tool `call_inference` " + "to request analysis from the allowed local image-capable model. The MCP request must include a text part plus actual " + "image data or an `image_reference` path from this context; a placeholder ID alone is not image data." +) + + +@dataclass(frozen=True) +class RedactedImage: + placeholder: str + source: str + reference: str | None = None + + +@dataclass(frozen=True) +class RedactionResult: + payload: dict[str, Any] + images: list[RedactedImage] + + +class DiskImageStore: + def __init__(self, directory: Path) -> None: + self.directory = directory + + def store(self, placeholder: str, image_value: Any) -> str: + self.directory.mkdir(parents=True, exist_ok=True) + serialized = json.dumps(image_value, sort_keys=True).encode("utf-8") + digest = hashlib.sha256(serialized).hexdigest()[:16] + + data_url = _find_data_url(image_value) + if data_url: + match = DATA_URL_RE.match(data_url) + if match: + extension = _extension_for_mime(match.group("mime")) + path = self.directory / f"{placeholder.lower()}-{digest}.{extension}" + path.write_bytes(base64.b64decode(match.group("data"))) + return str(path) + + path = self.directory / f"{placeholder.lower()}-{digest}.json" + path.write_text(json.dumps(image_value, indent=2, sort_keys=True), encoding="utf-8") + return str(path) + + +def redact_images( + payload: dict[str, Any], + store: DiskImageStore | None = None, + replacement_style: str = "mcp", + redaction_context: str | None = None, +) -> RedactionResult: + redacted_payload = copy.deepcopy(payload) + images: list[RedactedImage] = [] + + for message in redacted_payload.get("messages", []): + if not isinstance(message, dict): + continue + content = message.get("content") + if isinstance(content, list): + message["content"] = [ + _redact_content_part(part, images, store, replacement_style, redaction_context) for part in content + ] + elif isinstance(content, str) and _looks_like_image_data_url(content): + message["content"] = _replacement_text( + _record_image(content, "message.content", images, store), replacement_style, redaction_context + ) + + return RedactionResult(payload=redacted_payload, images=images) + + +def summarize_payload(payload: dict[str, Any]) -> dict[str, Any]: + messages = payload.get("messages", []) + summary: dict[str, Any] = { + "model": payload.get("model"), + "message_count": len(messages) if isinstance(messages, list) else 0, + "roles": [], + "content_part_types": [], + "has_image": False, + } + + if not isinstance(messages, list): + return summary + + content_part_types: list[str] = [] + roles: list[str] = [] + has_image = False + + for message in messages: + if not isinstance(message, dict): + continue + roles.append(str(message.get("role", "unknown"))) + content = message.get("content") + if isinstance(content, list): + for part in content: + if isinstance(part, dict): + part_type = str(part.get("type", "unknown")) + content_part_types.append(part_type) + has_image = has_image or _is_image_part(part) + elif isinstance(content, str): + content_part_types.append("text") + has_image = has_image or _looks_like_image_data_url(content) + + summary["roles"] = roles + summary["content_part_types"] = content_part_types + summary["has_image"] = has_image + return summary + + +def last_message_has_image(payload: dict[str, Any]) -> bool: + messages = payload.get("messages") + if not isinstance(messages, list) or not messages: + return False + + last_message = messages[-1] + if not isinstance(last_message, dict): + return False + + return message_has_image(last_message) + + +def message_has_image(message: dict[str, Any]) -> bool: + content = message.get("content") + if isinstance(content, list): + return any(isinstance(part, dict) and _is_image_part(part) for part in content) + if isinstance(content, str): + return _looks_like_image_data_url(content) + return False + + +def _redact_content_part( + part: Any, + images: list[RedactedImage], + store: DiskImageStore | None, + replacement_style: str, + redaction_context: str | None = None, +) -> Any: + if not isinstance(part, dict): + return part + + if _is_image_part(part): + image = _record_image(part, "message.content[]", images, store) + return {"type": "text", "text": _replacement_text(image, replacement_style, redaction_context)} + + return part + + +def _is_image_part(part: dict[str, Any]) -> bool: + part_type = str(part.get("type", "")).lower() + if part_type in IMAGE_KEYWORDS: + return True + if "image_url" in part or "input_image" in part: + return True + for value in part.values(): + if isinstance(value, str) and _looks_like_image_data_url(value): + return True + if isinstance(value, dict) and any(str(key).lower() in IMAGE_KEYWORDS for key in value): + return True + return False + + +def _looks_like_image_data_url(value: str) -> bool: + return bool(DATA_URL_RE.match(value.strip())) + + +def _find_data_url(value: Any) -> str | None: + if isinstance(value, str) and _looks_like_image_data_url(value): + return value.strip() + if isinstance(value, dict): + for nested in value.values(): + data_url = _find_data_url(nested) + if data_url: + return data_url + if isinstance(value, list): + for nested in value: + data_url = _find_data_url(nested) + if data_url: + return data_url + return None + + +def _record_image( + image_value: Any, + source: str, + images: list[RedactedImage], + store: DiskImageStore | None, +) -> RedactedImage: + placeholder = f"[REDACTED_IMAGE_{len(images) + 1}]" + reference = store.store(placeholder.strip("[]"), image_value) if store else None + image = RedactedImage(placeholder=placeholder, source=source, reference=reference) + images.append(image) + return image + + +def _replacement_text(image: RedactedImage, replacement_style: str, redaction_context: str | None = None) -> str: + text = f"{image.placeholder} image content redacted before upstream inference." + if replacement_style == "short": + return text + + text += f" {redaction_context or REDACTION_CONTEXT}" + if image.reference: + text += ( + f" Local image reference available to MCP for {image.placeholder}: {image.reference}. " + "Use MCP payload content like " + "[{\"type\":\"text\",\"text\":\"Describe the image.\"}, " + f"{{\"type\":\"image_reference\",\"path\":\"{image.reference}\"}}]." + ) + return text + + +def _extension_for_mime(mime: str) -> str: + return { + "image/jpeg": "jpg", + "image/jpg": "jpg", + "image/png": "png", + "image/gif": "gif", + "image/webp": "webp", + }.get(mime.lower(), "bin") diff --git a/examples/opencode-privacy-redaction/proxy/src/redact_spike/upstream.py b/examples/opencode-privacy-redaction/proxy/src/redact_spike/upstream.py new file mode 100644 index 000000000..5b9d6a40e --- /dev/null +++ b/examples/opencode-privacy-redaction/proxy/src/redact_spike/upstream.py @@ -0,0 +1,129 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import logging +from collections.abc import AsyncIterator +from dataclasses import dataclass +from typing import Any + +import httpx + +from redact_spike.config import Settings + + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class UpstreamResponse: + status_code: int + body: Any + + +class NvidiaInferenceClient: + def __init__( + self, + settings: Settings, + client: httpx.AsyncClient | None = None, + base_url: str | None = None, + model: str | None = None, + api_key: str | None = None, + require_api_key: bool = True, + ) -> None: + self.settings = settings + self.client = client + self.base_url = base_url or settings.upstream_base_url + self.model = model or settings.upstream_model + self.api_key = api_key if api_key is not None else settings.nvidia_api_key + self.require_api_key = require_api_key + + async def chat_completions(self, payload: dict[str, Any]) -> UpstreamResponse: + if self.require_api_key and not self.api_key: + raise RuntimeError("NVIDIA_API_KEY is required for upstream inference calls") + + body = dict(payload) + body.setdefault("model", self.model) + + headers = self._headers() + + close_client = self.client is None + client = self.client or httpx.AsyncClient(timeout=60) + url = chat_completions_url(self.base_url) + logger.info("upstream_request url=%s model=%s stream=%s", url, body.get("model"), body.get("stream", False)) + try: + response = await client.post( + url, + headers=headers, + json=body, + ) + try: + response_body: Any = response.json() + except ValueError: + response_body = {"error": response.text} + logger.info( + "upstream_response url=%s status=%s body=%s", + url, + response.status_code, + _sanitize_for_log(response_body), + ) + return UpstreamResponse(status_code=response.status_code, body=response_body) + finally: + if close_client: + await client.aclose() + + async def stream_chat_completions(self, payload: dict[str, Any]) -> AsyncIterator[bytes]: + if self.require_api_key and not self.api_key: + raise RuntimeError("NVIDIA_API_KEY is required for upstream inference calls") + + body = dict(payload) + body.setdefault("model", self.model) + + headers = self._headers(accept="text/event-stream") + + close_client = self.client is None + client = self.client or httpx.AsyncClient(timeout=httpx.Timeout(60, read=None)) + url = chat_completions_url(self.base_url) + logger.info("upstream_stream_request url=%s model=%s", url, body.get("model")) + try: + async with client.stream( + "POST", + url, + headers=headers, + json=body, + ) as response: + logger.info("upstream_stream_response url=%s status=%s", url, response.status_code) + async for chunk in response.aiter_bytes(): + yield chunk + finally: + if close_client: + await client.aclose() + + def _headers(self, accept: str | None = None) -> dict[str, str]: + headers = {"Content-Type": "application/json"} + if accept: + headers["Accept"] = accept + if self.api_key: + headers["Authorization"] = f"Bearer {self.api_key}" + return headers + + +def chat_completions_url(base_url: str) -> str: + base = base_url.rstrip("/") + if base.endswith("/v1"): + return f"{base}/chat/completions" + return f"{base}/v1/chat/completions" + + +def _sanitize_for_log(value: Any) -> Any: + if isinstance(value, dict): + return {key: _sanitize_for_log(nested) for key, nested in value.items()} + if isinstance(value, list): + return [_sanitize_for_log(item) for item in value] + if isinstance(value, str): + if value.strip().startswith("data:image/"): + return "[REDACTED_IMAGE_DATA_URL]" + if len(value) > 500: + return f"{value[:500]}...[TRUNCATED {len(value) - 500} chars]" + return value diff --git a/examples/opencode-privacy-redaction/proxy/tests/test_config.py b/examples/opencode-privacy-redaction/proxy/tests/test_config.py new file mode 100644 index 000000000..ffa85dd7d --- /dev/null +++ b/examples/opencode-privacy-redaction/proxy/tests/test_config.py @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from pathlib import Path + +from redact_spike.config import Settings + + +def test_settings_loads_dotenv_file(monkeypatch, tmp_path: Path) -> None: + env_file = tmp_path / ".env" + env_file.write_text( + "NVIDIA_API_KEY=from-dotenv\nUPSTREAM_MODEL=model-from-dotenv\nPROXY_PORT=9999\n", + encoding="utf-8", + ) + monkeypatch.setenv("ENV_FILE", str(env_file)) + monkeypatch.delenv("NVIDIA_API_KEY", raising=False) + monkeypatch.delenv("UPSTREAM_MODEL", raising=False) + monkeypatch.delenv("PROXY_PORT", raising=False) + + settings = Settings.from_env() + + assert settings.nvidia_api_key == "from-dotenv" + assert settings.upstream_model == "model-from-dotenv" + assert settings.proxy_port == 9999 + + +def test_dotenv_values_override_environment_values(monkeypatch, tmp_path: Path) -> None: + env_file = tmp_path / ".env" + env_file.write_text("NVIDIA_API_KEY=from-dotenv\n", encoding="utf-8") + monkeypatch.setenv("ENV_FILE", str(env_file)) + monkeypatch.setenv("NVIDIA_API_KEY", "from-environment") + + settings = Settings.from_env() + + assert settings.nvidia_api_key == "from-dotenv" diff --git a/examples/opencode-privacy-redaction/proxy/tests/test_content_policy.py b/examples/opencode-privacy-redaction/proxy/tests/test_content_policy.py new file mode 100644 index 000000000..6cf359d44 --- /dev/null +++ b/examples/opencode-privacy-redaction/proxy/tests/test_content_policy.py @@ -0,0 +1,52 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from pathlib import Path + +from redact_spike.content_policy import load_image_content_rule + + +def _write(tmp_path: Path, body: str) -> Path: + path = tmp_path / "content-policy.yaml" + path.write_text(body, encoding="utf-8") + return path + + +def test_returns_none_when_path_missing(tmp_path): + assert load_image_content_rule(None) is None + assert load_image_content_rule(tmp_path / "nope.yaml") is None + + +def test_returns_none_without_image_key(tmp_path): + path = _write(tmp_path, "version: 1\ncontent_policy:\n content_type: {}\n") + assert load_image_content_rule(path) is None + + +def test_redact_rule_with_description(tmp_path): + path = _write( + tmp_path, + "content_policy:\n" + " content_type:\n" + " image: redact\n" + " redact_description: Use the local_model MCP server.\n", + ) + rule = load_image_content_rule(path) + assert rule is not None + assert rule.should_redact is True + assert rule.description == "Use the local_model MCP server." + + +def test_allow_rule(tmp_path): + path = _write(tmp_path, "content_policy:\n content_type:\n image: allow\n") + rule = load_image_content_rule(path) + assert rule is not None + assert rule.should_redact is False + + +def test_live_toggle_is_reread(tmp_path): + path = _write(tmp_path, "content_policy:\n content_type:\n image: allow\n") + assert load_image_content_rule(path).should_redact is False + path.write_text("content_policy:\n content_type:\n image: redact\n", encoding="utf-8") + assert load_image_content_rule(path).should_redact is True diff --git a/examples/opencode-privacy-redaction/proxy/tests/test_mcp_server.py b/examples/opencode-privacy-redaction/proxy/tests/test_mcp_server.py new file mode 100644 index 000000000..bbe7ed3b9 --- /dev/null +++ b/examples/opencode-privacy-redaction/proxy/tests/test_mcp_server.py @@ -0,0 +1,110 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from typing import Any + +import pytest + +from redact_spike import mcp_server +from redact_spike.config import Settings +from redact_spike.upstream import UpstreamResponse + + +class FakeNvidiaInferenceClient: + def __init__(self, settings: Settings, **kwargs: Any) -> None: + self.settings = settings + self.kwargs = kwargs + + async def chat_completions(self, payload: dict[str, Any]) -> UpstreamResponse: + return UpstreamResponse(status_code=200, body={"echo": payload, "model": self.kwargs["model"], "base_url": self.kwargs["base_url"]}) + + +async def test_call_inference_impl_returns_openai_shaped_payload(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(mcp_server, "NvidiaInferenceClient", FakeNvidiaInferenceClient) + + payload = {"messages": [{"role": "user", "content": "describe image"}]} + response = await mcp_server.call_inference_impl( + payload, + settings=Settings(private_upstream_base_url="http://localhost:1234", private_upstream_model="qwen/qwen3.6-27b"), + ) + + assert response == { + "status_code": 200, + "body": { + "echo": {"messages": [{"role": "user", "content": "describe image"}], "model": "qwen/qwen3.6-27b"}, + "model": "qwen/qwen3.6-27b", + "base_url": "http://localhost:1234", + }, + } + + +def test_normalize_mcp_payload_forces_configured_model() -> None: + payload = { + "model": "local-image-model", + "messages": [{"role": "user", "content": "hello"}], + } + + normalized = mcp_server.normalize_mcp_payload(payload, Settings(private_upstream_model="qwen/qwen3.6-27b")) + + assert normalized["model"] == "qwen/qwen3.6-27b" + assert payload["model"] == "local-image-model" + + +def test_normalize_mcp_payload_converts_image_placeholder_to_text() -> None: + payload = { + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Describe the image."}, + {"type": "image_placeholder", "id": "REDACTED_IMAGE_1"}, + ], + } + ] + } + + normalized = mcp_server.normalize_mcp_payload(payload, Settings(private_upstream_model="qwen/qwen3.6-27b")) + + assert normalized["model"] == "qwen/qwen3.6-27b" + assert normalized["messages"][0]["content"] == [ + {"type": "text", "text": "Describe the image."}, + { + "type": "text", + "text": ( + "Image placeholder [REDACTED_IMAGE_1] was referenced, but no image bytes or local image reference were " + "provided to this MCP call. Retry with a text part plus an image_reference part using the local path " + "from the redaction context." + ), + }, + ] + + +def test_normalize_mcp_payload_converts_image_reference_to_data_url(tmp_path) -> None: + image = tmp_path / "redacted.png" + image.write_bytes(b"fake image bytes") + payload = { + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Describe the image."}, + {"type": "image_reference", "path": str(image)}, + ], + } + ] + } + + normalized = mcp_server.normalize_mcp_payload(payload, Settings(private_upstream_model="qwen/qwen3.6-27b")) + + assert normalized["messages"][0]["content"] == [ + {"type": "text", "text": "Describe the image."}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,ZmFrZSBpbWFnZSBieXRlcw=="}}, + ] + + +def test_create_mcp_server() -> None: + server = mcp_server.create_mcp_server(settings=Settings(nvidia_api_key="test")) + + assert server.name == "redacted-inference" diff --git a/examples/opencode-privacy-redaction/proxy/tests/test_proxy.py b/examples/opencode-privacy-redaction/proxy/tests/test_proxy.py new file mode 100644 index 000000000..15298e9e9 --- /dev/null +++ b/examples/opencode-privacy-redaction/proxy/tests/test_proxy.py @@ -0,0 +1,403 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import logging +import json +from pathlib import Path +from typing import Any + +import httpx + +from redact_spike.config import Settings +from redact_spike.proxy import create_app +from redact_spike.upstream import UpstreamResponse + + +class RecordingUpstream: + def __init__(self) -> None: + self.payloads: list[dict[str, Any]] = [] + + async def chat_completions(self, payload: dict[str, Any]) -> UpstreamResponse: + self.payloads.append(payload) + return UpstreamResponse(status_code=200, body={"id": "chatcmpl-test", "choices": []}) + + async def stream_chat_completions(self, payload: dict[str, Any]): + self.payloads.append(payload) + yield b"data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\n" + yield b"data: [DONE]\n\n" + + +class ReasoningUpstream: + def __init__(self) -> None: + self.payloads: list[dict[str, Any]] = [] + + async def chat_completions(self, payload: dict[str, Any]) -> UpstreamResponse: + self.payloads.append(payload) + return UpstreamResponse( + status_code=200, + body={ + "choices": [ + { + "message": { + "role": "assistant", + "content": "answer", + "reasoning_content": "private reasoning", + } + } + ] + }, + ) + + async def stream_chat_completions(self, payload: dict[str, Any]): + self.payloads.append(payload) + yield b'data: {"choices":[{"delta":{"role":"assistant","reasoning_content":"private"}}]}\n\n' + yield b'data: {"choices":[{"delta":{"reasoning_content":" reasoning"}}]}\n\n' + yield b"data: [DONE]\n\n" + + +async def test_proxy_forwards_chat_completion_payload_when_redaction_disabled() -> None: + upstream = RecordingUpstream() + settings = Settings(nvidia_api_key="test", redaction_enabled=False) + app = create_app(settings=settings, upstream_client=upstream) + payload = { + "model": "openai/openai/gpt-5.5", + "messages": [{"role": "user", "content": "Hello"}], + "max_tokens": 1024, + } + + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post("/v1/chat/completions", json=payload) + + assert response.status_code == 200 + assert response.json() == {"id": "chatcmpl-test", "choices": []} + assert upstream.payloads == [payload] + + +async def test_proxy_redacts_image_payload_before_forwarding() -> None: + upstream = RecordingUpstream() + settings = Settings(nvidia_api_key="test", redaction_enabled=True) + app = create_app(settings=settings, upstream_client=upstream) + payload = { + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "What is in this image?"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,aW1hZ2U="}}, + ], + } + ] + } + + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post("/v1/chat/completions", json=payload) + + assert response.status_code == 200 + forwarded = upstream.payloads[0] + assert "data:image/png" not in str(forwarded) + assert "[REDACTED_IMAGE_1]" in str(forwarded) + assert "pre-installed MCP tool `call_inference`" in forwarded["messages"][0]["content"][1]["text"] + + +async def test_proxy_streams_chat_completion_payloads() -> None: + upstream = RecordingUpstream() + settings = Settings(nvidia_api_key="test", redaction_enabled=False) + app = create_app(settings=settings, upstream_client=upstream) + payload = {"stream": True, "messages": [{"role": "user", "content": "Hello"}]} + + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post("/v1/chat/completions", json=payload) + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/event-stream") + assert response.content == b"data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\ndata: [DONE]\n\n" + assert upstream.payloads == [payload] + + +async def test_proxy_supports_chat_completion_route_aliases() -> None: + upstream = RecordingUpstream() + settings = Settings(nvidia_api_key="test", redaction_enabled=False) + app = create_app(settings=settings, upstream_client=upstream) + payload = {"messages": [{"role": "user", "content": "Hello"}]} + + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + for path in ["/chat/completions", "/chat/completion", "/v1/chat/completion"]: + response = await client.post(path, json=payload) + assert response.status_code == 200 + + assert upstream.payloads == [payload, payload, payload] + + +async def test_proxy_lists_configured_model() -> None: + upstream = RecordingUpstream() + settings = Settings(nvidia_api_key="test", upstream_model="openai/openai/gpt-5.5") + app = create_app(settings=settings, upstream_client=upstream) + + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/v1/models") + + assert response.status_code == 200 + assert response.json() == { + "object": "list", + "data": [ + { + "id": "openai/openai/gpt-5.5", + "object": "model", + "owned_by": "nvidia", + } + ], + } + + +async def test_proxy_rejects_non_object_json() -> None: + upstream = RecordingUpstream() + app = create_app(settings=Settings(nvidia_api_key="test"), upstream_client=upstream) + + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post("/v1/chat/completions", json=["not", "an", "object"]) + + assert response.status_code == 400 + assert upstream.payloads == [] + + +async def test_proxy_logs_unknown_requests(caplog) -> None: + upstream = RecordingUpstream() + app = create_app(settings=Settings(nvidia_api_key="test"), upstream_client=upstream) + + transport = httpx.ASGITransport(app=app) + with caplog.at_level(logging.INFO, logger="redact_spike.proxy"): + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/definitely-unknown", headers={"Authorization": "Bearer local"}) + + assert response.status_code == 404 + assert "method=GET path=/definitely-unknown" in caplog.text + assert "path=/definitely-unknown status=404" in caplog.text + assert "Bearer local" not in caplog.text + assert "[REDACTED]" in caplog.text + + +async def test_proxy_writes_full_request_body_to_file(caplog, tmp_path) -> None: + upstream = RecordingUpstream() + app = create_app(settings=Settings(nvidia_api_key="test", request_log_dir=tmp_path), upstream_client=upstream) + payload = { + "model": "bad-model", + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Hello"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,aW1hZ2U="}}, + ], + } + ], + } + + transport = httpx.ASGITransport(app=app) + with caplog.at_level(logging.INFO, logger="redact_spike.proxy"): + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post("/v1/chat/completions", json=payload) + + assert response.status_code == 200 + assert "bad-model" in caplog.text + assert "data:image/png" not in caplog.text + request_log_files = list(tmp_path.glob("*incoming*.json")) + outbound_log_files = list(tmp_path.glob("*outbound-redacted-request.json")) + response_log_files = list(tmp_path.glob("*upstream-response.json")) + assert len(request_log_files) == 1 + assert len(outbound_log_files) == 1 + assert len(response_log_files) == 1 + request_log = json.loads(request_log_files[0].read_text(encoding="utf-8")) + outbound_log = json.loads(outbound_log_files[0].read_text(encoding="utf-8")) + response_log = json.loads(response_log_files[0].read_text(encoding="utf-8")) + assert request_log["path"] == "/v1/chat/completions" + assert request_log["body_json"] == payload + assert "data:image/png" in request_log["body_text"] + assert "data:image/png" not in str(outbound_log) + assert "[REDACTED_IMAGE_1]" in str(outbound_log) + assert response_log == {"status_code": 200, "body": {"id": "chatcmpl-test", "choices": []}} + + +async def test_proxy_logs_redaction_and_upstream_lifecycle(caplog, tmp_path) -> None: + upstream = RecordingUpstream() + app = create_app(settings=Settings(nvidia_api_key="test", request_log_dir=tmp_path), upstream_client=upstream) + payload = { + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "What is in this image?"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,aW1hZ2U="}}, + ], + } + ] + } + + transport = httpx.ASGITransport(app=app) + with caplog.at_level(logging.INFO, logger="redact_spike.proxy"): + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post("/v1/chat/completions", json=payload) + + assert response.status_code == 200 + assert "chat_completion_received" in caplog.text + assert "image_redaction_started" in caplog.text + assert "image_redaction_applied count=1" in caplog.text + assert "redacted_request_captured" in caplog.text + assert "calling_upstream stream=false" in caplog.text + assert "upstream_response_captured" in caplog.text + + +async def test_proxy_captures_streaming_upstream_response(tmp_path) -> None: + upstream = RecordingUpstream() + app = create_app( + settings=Settings(nvidia_api_key="test", redaction_enabled=False, request_log_dir=tmp_path), + upstream_client=upstream, + ) + payload = {"stream": True, "messages": [{"role": "user", "content": "Hello"}]} + + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post("/v1/chat/completions", json=payload) + + assert response.status_code == 200 + response_log_files = list(tmp_path.glob("*upstream-stream-response.json")) + assert len(response_log_files) == 1 + response_log = json.loads(response_log_files[0].read_text(encoding="utf-8")) + assert response_log["body_text"] == "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\ndata: [DONE]\n\n" + + +async def test_private_switch_routes_last_message_image_to_private_model_without_redaction(tmp_path: Path) -> None: + public_upstream = RecordingUpstream() + private_upstream = RecordingUpstream() + settings = Settings( + nvidia_api_key="test", + private_upstream_model="private-image-model", + request_log_dir=tmp_path, + ) + app = create_app( + settings=settings, + upstream_client=public_upstream, + private_upstream_client=private_upstream, + private_model_switch=True, + ) + payload = { + "model": "public-model", + "messages": [ + {"role": "system", "content": "You are helpful."}, + { + "role": "user", + "content": [ + {"type": "text", "text": "Describe this."}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,aW1hZ2U="}}, + ], + }, + ], + } + + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post("/v1/chat/completions", json=payload) + + assert response.status_code == 200 + assert public_upstream.payloads == [] + forwarded = private_upstream.payloads[0] + assert forwarded["model"] == "private-image-model" + assert "data:image/png" in str(forwarded) + + +async def test_private_switch_redacts_older_images_when_last_message_has_no_image() -> None: + upstream = RecordingUpstream() + settings = Settings(nvidia_api_key="test", private_upstream_model="private-image-model") + app = create_app(settings=settings, upstream_client=upstream, private_upstream_client=RecordingUpstream(), private_model_switch=True) + payload = { + "model": "public-model", + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Old image."}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,aW1hZ2U="}}, + ], + }, + {"role": "assistant", "content": "What should I do?"}, + {"role": "user", "content": "Answer without using that image."}, + ], + } + + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post("/v1/chat/completions", json=payload) + + assert response.status_code == 200 + forwarded = upstream.payloads[0] + assert forwarded["model"] == "public-model" + assert "data:image/png" not in str(forwarded) + assert "[REDACTED_IMAGE_1] image content redacted before upstream inference." in str(forwarded) + assert "pre-installed MCP" not in str(forwarded) + + +async def test_private_switch_copies_reasoning_content_to_provider_specific_fields(tmp_path: Path) -> None: + private_upstream = ReasoningUpstream() + app = create_app( + settings=Settings(nvidia_api_key="test", private_upstream_model="private-image-model", request_log_dir=tmp_path), + upstream_client=RecordingUpstream(), + private_upstream_client=private_upstream, + private_model_switch=True, + ) + payload = { + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Describe this."}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,aW1hZ2U="}}, + ], + } + ] + } + + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post("/v1/chat/completions", json=payload) + + message = response.json()["choices"][0]["message"] + assert message["reasoning_content"] == "private reasoning" + assert message["provider_specific_fields"]["reasoning_content"] == "private reasoning" + + +async def test_private_switch_stream_copies_reasoning_content_to_provider_specific_fields(tmp_path: Path) -> None: + private_upstream = ReasoningUpstream() + app = create_app( + settings=Settings(nvidia_api_key="test", private_upstream_model="private-image-model", request_log_dir=tmp_path), + upstream_client=RecordingUpstream(), + private_upstream_client=private_upstream, + private_model_switch=True, + ) + payload = { + "stream": True, + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Describe this."}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,aW1hZ2U="}}, + ], + } + ], + } + + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post("/v1/chat/completions", json=payload) + + assert response.status_code == 200 + assert b'"reasoning_content":"private"' in response.content + assert b'"provider_specific_fields":{"reasoning_content":"private"}' in response.content + assert b'"provider_specific_fields":{"reasoning_content":" reasoning"}' in response.content diff --git a/examples/opencode-privacy-redaction/proxy/tests/test_redaction.py b/examples/opencode-privacy-redaction/proxy/tests/test_redaction.py new file mode 100644 index 000000000..ad6f2d3da --- /dev/null +++ b/examples/opencode-privacy-redaction/proxy/tests/test_redaction.py @@ -0,0 +1,141 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import base64 + +from redact_spike.redaction import DiskImageStore, last_message_has_image, redact_images, summarize_payload + + +def image_data_url() -> str: + return "data:image/png;base64," + base64.b64encode(b"fake png bytes").decode("ascii") + + +def test_redacts_image_parts_and_adds_mcp_context_at_image_location() -> None: + payload = { + "model": "openai/openai/gpt-5.5", + "messages": [ + {"role": "system", "content": "You are helpful."}, + { + "role": "user", + "content": [ + {"type": "text", "text": "Am I dressed for rain?"}, + {"type": "image_url", "image_url": {"url": image_data_url()}}, + ], + }, + ], + } + + result = redact_images(payload) + + assert len(result.images) == 1 + assert result.images[0].placeholder == "[REDACTED_IMAGE_1]" + assert image_data_url() not in str(result.payload) + assert result.payload["messages"][0] == {"role": "system", "content": "You are helpful."} + replacement = result.payload["messages"][1]["content"][1] + assert replacement["type"] == "text" + assert "This image was redacted" in replacement["text"] + assert "pre-installed MCP tool `call_inference`" in replacement["text"] + assert "[REDACTED_IMAGE_1]" in replacement["text"] + + +def test_can_store_redacted_data_urls_on_disk(tmp_path) -> None: + payload = { + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Describe this."}, + {"type": "image_url", "image_url": {"url": image_data_url()}}, + ], + } + ] + } + + result = redact_images(payload, store=DiskImageStore(tmp_path)) + + assert result.images[0].reference is not None + assert result.images[0].reference.endswith(".png") + assert (tmp_path / result.images[0].reference.split("/")[-1]).read_bytes() == b"fake png bytes" + replacement = result.payload["messages"][0]["content"][1] + assert "Local image reference available to MCP" in replacement["text"] + assert str(tmp_path) in replacement["text"] + assert image_data_url() not in str(result.payload) + + +def test_short_redaction_omits_mcp_context() -> None: + payload = { + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Describe this."}, + {"type": "image_url", "image_url": {"url": image_data_url()}}, + ], + } + ] + } + + result = redact_images(payload, replacement_style="short") + + assert result.payload["messages"][0]["content"][1] == { + "type": "text", + "text": "[REDACTED_IMAGE_1] image content redacted before upstream inference.", + } + + +def test_last_message_has_image_only_checks_latest_message() -> None: + payload = { + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Old image."}, + {"type": "image_url", "image_url": {"url": image_data_url()}}, + ], + }, + {"role": "assistant", "content": "What should I do with it?"}, + {"role": "user", "content": "Never mind, answer without the image."}, + ] + } + + assert last_message_has_image(payload) is False + + payload["messages"][-1] = { + "role": "user", + "content": [ + {"type": "text", "text": "Now describe this."}, + {"type": "image_url", "image_url": {"url": image_data_url()}}, + ], + } + + assert last_message_has_image(payload) is True + + +def test_summarize_payload_reports_structure_without_prompt_text() -> None: + payload = { + "model": "model-a", + "messages": [ + {"role": "system", "content": "secret system prompt"}, + { + "role": "user", + "content": [ + {"type": "text", "text": "secret prompt text"}, + {"type": "image_url", "image_url": {"url": image_data_url()}}, + ], + }, + ], + } + + summary = summarize_payload(payload) + + assert summary == { + "model": "model-a", + "message_count": 2, + "roles": ["system", "user"], + "content_part_types": ["text", "text", "image_url"], + "has_image": True, + } + assert "secret" not in str(summary) + assert image_data_url() not in str(summary) diff --git a/examples/opencode-privacy-redaction/proxy/tests/test_upstream.py b/examples/opencode-privacy-redaction/proxy/tests/test_upstream.py new file mode 100644 index 000000000..af2e555a0 --- /dev/null +++ b/examples/opencode-privacy-redaction/proxy/tests/test_upstream.py @@ -0,0 +1,108 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import logging + +import httpx + +from redact_spike.config import Settings +from redact_spike.upstream import NvidiaInferenceClient, chat_completions_url + + +async def test_upstream_client_adds_auth_header_and_default_model() -> None: + seen_request: httpx.Request | None = None + + async def handler(request: httpx.Request) -> httpx.Response: + nonlocal seen_request + seen_request = request + return httpx.Response(200, json={"choices": [{"message": {"content": "ok"}}]}) + + http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + settings = Settings(nvidia_api_key="secret", upstream_base_url="https://upstream.test", upstream_model="default-model") + client = NvidiaInferenceClient(settings=settings, client=http_client) + + response = await client.chat_completions({"messages": []}) + + assert response.status_code == 200 + assert response.body == {"choices": [{"message": {"content": "ok"}}]} + assert seen_request is not None + assert str(seen_request.url) == "https://upstream.test/v1/chat/completions" + assert seen_request.headers["authorization"] == "Bearer secret" + assert seen_request.headers["content-type"] == "application/json" + assert b'"model":"default-model"' in seen_request.content + + await http_client.aclose() + + +def test_chat_completions_url_accepts_base_url_with_or_without_v1() -> None: + assert chat_completions_url("https://inference-api.nvidia.com") == ( + "https://inference-api.nvidia.com/v1/chat/completions" + ) + assert chat_completions_url("https://inference-api.nvidia.com/v1") == ( + "https://inference-api.nvidia.com/v1/chat/completions" + ) + assert chat_completions_url("https://inference-api.nvidia.com/v1/") == ( + "https://inference-api.nvidia.com/v1/chat/completions" + ) + + +async def test_upstream_client_requires_api_key() -> None: + client = NvidiaInferenceClient(settings=Settings(nvidia_api_key=None)) + + try: + await client.chat_completions({"messages": []}) + except RuntimeError as error: + assert "NVIDIA_API_KEY" in str(error) + else: + raise AssertionError("expected missing API key error") + + +async def test_upstream_client_can_call_local_openai_compatible_server_without_auth() -> None: + seen_request: httpx.Request | None = None + + async def handler(request: httpx.Request) -> httpx.Response: + nonlocal seen_request + seen_request = request + return httpx.Response(200, json={"choices": []}) + + http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + client = NvidiaInferenceClient( + settings=Settings(), + client=http_client, + base_url="http://localhost:1234", + model="qwen/qwen3.6-27b", + api_key=None, + require_api_key=False, + ) + + response = await client.chat_completions({"messages": []}) + + assert response.status_code == 200 + assert seen_request is not None + assert str(seen_request.url) == "http://localhost:1234/v1/chat/completions" + assert "authorization" not in seen_request.headers + assert b'"model":"qwen/qwen3.6-27b"' in seen_request.content + + await http_client.aclose() + + +async def test_upstream_client_logs_url_status_and_body(caplog) -> None: + async def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(404, json={"error": {"message": "not found"}}) + + http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + settings = Settings(nvidia_api_key="secret", upstream_base_url="https://upstream.test/v1") + client = NvidiaInferenceClient(settings=settings, client=http_client) + + with caplog.at_level(logging.INFO, logger="redact_spike.upstream"): + response = await client.chat_completions({"model": "openai/openai/gpt-5.5", "messages": []}) + + assert response.status_code == 404 + assert "upstream_request url=https://upstream.test/v1/chat/completions" in caplog.text + assert "model=openai/openai/gpt-5.5" in caplog.text + assert "upstream_response url=https://upstream.test/v1/chat/completions status=404" in caplog.text + assert "not found" in caplog.text + + await http_client.aclose() diff --git a/examples/opencode-privacy-redaction/sandbox/opencode-provision.sh b/examples/opencode-privacy-redaction/sandbox/opencode-provision.sh new file mode 100755 index 000000000..3fa97aae4 --- /dev/null +++ b/examples/opencode-privacy-redaction/sandbox/opencode-provision.sh @@ -0,0 +1,33 @@ +#!/bin/sh + +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Provision opencode inside an OpenShell sandbox, wired to inference.local plus +# the host-side local-model MCP. opencode is preinstalled on the base image. +# The opencode.json config is fed on stdin (ssh '...' < opencode.json). +set -e +export HOME=/sandbox + +CFG_DIR="$HOME/.config/opencode" +mkdir -p "$CFG_DIR" + +# opencode treats its working directory as the project root and needs one to +# start a session in. +WORKSPACE="$HOME/workspace" +echo "== creating opencode workspace ($WORKSPACE) ==" +mkdir -p "$WORKSPACE" + +echo "== writing opencode config ($CFG_DIR/opencode.json) ==" +cat > "$CFG_DIR/opencode.json" + +echo "== opencode version ==" +opencode --version + +echo "== configured providers/models ==" +opencode models 2>&1 | grep -i inference-local || true + +echo "== registered MCP servers ==" +opencode mcp list 2>&1 || true + +echo "== provision complete ==" diff --git a/examples/opencode-privacy-redaction/sandbox/opencode.json b/examples/opencode-privacy-redaction/sandbox/opencode.json new file mode 100644 index 000000000..34aa02587 --- /dev/null +++ b/examples/opencode-privacy-redaction/sandbox/opencode.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://opencode.ai/config.json", + "autoupdate": false, + "share": "disabled", + "model": "inference-local/openai/openai/gpt-5.5", + "small_model": "inference-local/openai/openai/gpt-5.5", + "provider": { + "inference-local": { + "npm": "@ai-sdk/openai-compatible", + "name": "OpenShell inference.local", + "options": { + "baseURL": "https://inference.local/v1", + "apiKey": "dummy" + }, + "models": { + "openai/openai/gpt-5.5": { + "name": "gpt-5.5 (inference.local)", + "modalities": { + "input": ["text", "image", "pdf"], + "output": ["text"] + }, + "limit": { + "context": 131072, + "output": 32768 + } + } + } + } + }, + "mcp": { + "local_model": { + "type": "remote", + "url": "http://host.openshell.internal:8001/mcp", + "enabled": true, + "oauth": false + } + } +} diff --git a/examples/opencode-privacy-redaction/sandbox/setup-sandbox.sh b/examples/opencode-privacy-redaction/sandbox/setup-sandbox.sh new file mode 100755 index 000000000..1e9a10e7e --- /dev/null +++ b/examples/opencode-privacy-redaction/sandbox/setup-sandbox.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Create an OpenShell sandbox running opencode, wired to inference.local and the +# host-side local-model MCP, then start the opencode web UI and expose it. +# +# Prereqs (on the host): +# - An OpenShell gateway is registered and selected (openshell gateway list). +# - The redaction proxy (:8000) and MCP (:8001) are running: see ../proxy. +# - inference is routed to the proxy (see README "One-time gateway wiring"). +set -euo pipefail + +SANDBOX_NAME="${SANDBOX_NAME:-opencode}" +WEB_PORT="${WEB_PORT:-9119}" +SERVICE_NAME="${SERVICE_NAME:-web}" + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +POLICY="$HERE/../policies/opencode-policy.yaml" +CONFIG="$HERE/opencode.json" +PROVISION="$HERE/opencode-provision.sh" +SSH_CONFIG="/tmp/${SANDBOX_NAME}-ssh-config" + +echo "== creating sandbox '$SANDBOX_NAME' ==" +openshell sandbox create --name "$SANDBOX_NAME" --policy "$POLICY" --no-tty -- echo sandbox-ready + +echo "== fetching ssh config ==" +openshell sandbox ssh-config "$SANDBOX_NAME" > "$SSH_CONFIG" +SSH=(ssh -F "$SSH_CONFIG" "openshell-${SANDBOX_NAME}") + +echo "== provisioning opencode config ==" +"${SSH[@]}" 'cat > /tmp/opencode-provision.sh' < "$PROVISION" +"${SSH[@]}" 'cat > /tmp/opencode.json' < "$CONFIG" +"${SSH[@]}" 'sh /tmp/opencode-provision.sh < /tmp/opencode.json' + +echo "== starting opencode web on 127.0.0.1:${WEB_PORT} (inside sandbox) ==" +# setsid+nohup so the server survives the ssh session. Logs to /sandbox/web.log. +# NB: do not pkill by "opencode web" from a one-liner whose text contains that +# string - pkill -f would match its own shell. Kill by port/PID instead. +"${SSH[@]}" "export HOME=/sandbox; cd /sandbox/workspace; rm -f /sandbox/web.log; \ + setsid nohup opencode web --hostname 127.0.0.1 --port ${WEB_PORT} > /sandbox/web.log 2>&1 < /dev/null & echo launched" +sleep 6 +"${SSH[@]}" "curl -s -m 6 -o /dev/null -w 'sandbox web / = HTTP %{http_code}\n' http://127.0.0.1:${WEB_PORT}/" || true + +echo "== exposing web service via the gateway ==" +openshell service expose "$SANDBOX_NAME" "$WEB_PORT" "$SERVICE_NAME" + +echo +echo "Done. Open the URL printed above (http://${SANDBOX_NAME}--${SERVICE_NAME}.openshell.localhost:/)." +echo "Verify the wiring:" +echo " ${SSH[*]} 'export HOME=/sandbox; cd /sandbox/workspace; opencode run --model inference-local/openai/openai/gpt-5.5 \"say hi\"'"