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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions fern/versions/latest/pages/model-server/adapters-rewrites.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
---
title: "Request-Rewriting Interceptors"
description: "Interceptors that mutate the outbound request body before it reaches the upstream."
position: 6
---

Interceptors that mutate the outbound request body before it reaches the upstream. Run in the REQUEST stage.

| Name | Stage | Purpose |
|------|-------|---------|
| `drop_params` | request | Remove named parameters from the outbound body. |
| `payload_modifier` | request | Add, remove, and rename body fields. |
| `system_message` | request | Inject a system message (prepend / append / replace). |
| `consolidate_system` | request | Merge displaced system messages into one at position 0. |
| `modify_tools` | request | Strip or add properties on `tools[].function.parameters`. |
| `turn_counter` | request | Per-session turn budget; raises `GracefulError` (→ 429) on exhaustion. |

### `drop_params`

```yaml
- name: drop_params
config:
params: ["top_k", "frequency_penalty"]
```

### `payload_modifier`

```yaml
- name: payload_modifier
config:
params_to_remove: ["secret_field"]
params_to_add: { "max_completion_tokens": 4096 }
params_to_rename: { "old_name": "new_name" }
```

### `system_message`

```yaml
- name: system_message
config:
system_message: "You are a careful assistant."
strategy: prepend # one of: prepend | append | replace
```

### `consolidate_system`

```yaml
- name: consolidate_system
config:
separator: "\n\n"
```

### `modify_tools`

```yaml
- name: modify_tools
config:
strip_properties: ["internal_flag"]
add_properties:
reasoning:
type: string
description: "model's chain-of-thought"
```

### `turn_counter`

```yaml
- name: turn_counter
config:
every: 1 # log on every Nth turn
max_turns: 20 # null disables the budget
```

The session key is taken from `ctx.extra["session_id"]` (set by the middleware when the path matches `/s/<hex>/...`); otherwise a body-hash fallback is used.
68 changes: 68 additions & 0 deletions fern/versions/latest/pages/model-server/adapters.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
---
title: "Adapter Middleware"
description: "Interceptor-based middleware framework for Model, Agent, and Resources servers."
position: 3
---

Adapter middleware adds an interceptor chain to a server's request/response path. Each interceptor can observe or mutate the payload without the host server changing. Use it to inject system prompts, drop unsupported params, cache responses, count turns, normalize reasoning fields, log tokens, and so on.

`adapters` is opt-in. It is declared on `BaseResponsesAPIModelConfig`, `BaseResponsesAPIAgentConfig`, and `BaseResourcesServerConfig`, so every in-tree server inheriting from `SimpleResponsesAPIModel` / `SimpleResponsesAPIAgent` / `SimpleResourcesServer` accepts an `adapters` block automatically. Omitting it leaves behavior identical to the base server.

## Quickstart

Add an `adapters` list to any server config:

```yaml
policy_model:
responses_api_models:
openai_model:
openai_base_url: https://api.openai.com/v1
openai_api_key: ...
openai_model: gpt-4.1
adapters: # ← toggle: omit or null = OFF
- {name: logging, config: {}}
```

This PR ships the **framework** (`AdapterPipeline`, `InterceptorRegistry`, `install_middleware`, `start_adapter_proxy`) plus two built-in interceptors:

| Name | Stage | Purpose |
|------|-------|---------|
| `logging` | request + response | Log request body keys and response status/latency. Canonical "did the chain fire?" probe. |
| `endpoint` | request → response | Drive the upstream HTTP call directly. **Only used by `start_adapter_proxy`** (standalone host mode); forbidden inside `install_middleware`. |

Additional interceptor families (observability, caching, request rewriting) ship in follow-on PRs.

## Host modes

The same `AdapterPipeline` runs in either of two hosting modes:

**Middleware mode** — `install_middleware(app, adapters)` attaches the pipeline to an existing FastAPI app via `app.middleware("http")`. The host server's own routing performs the upstream call via `call_next`. Used by every Model/Agent/Resources server when `adapters` is set on its config.

**Proxy mode** — `start_adapter_proxy(upstream_url, adapters)` launches a localhost uvicorn that hosts the pipeline with its own forwarding logic. Used by agents that bring their own SDK client (e.g. `claude_code_agent` with `anthropic_base_url`); the agent points its SDK's `*_BASE_URL` at the proxy URL via the `adapter_proxy` field on `BaseResponsesAPIAgentConfig`.

## Path-Based Session Scoping

Posts to `/s/<hex-id>/<path>` have the prefix stripped before forwarding, and `<hex-id>` is recorded as `ctx.extra["session_id"]`. Follow-on interceptors that key per-session (e.g. `turn_counter`, `caching`) use this id.

## Custom Interceptors

Register a class at runtime via `InterceptorRegistry.register`:

```python
from nemo_gym.adapters import InterceptorRegistry

InterceptorRegistry.register("my_interceptor", "myproject.adapters.my_interceptor")
```

The target module must expose a class named `Interceptor` that subclasses one of `RequestInterceptor`, `RequestToResponseInterceptor`, or `ResponseInterceptor` from `nemo_gym.adapters.types`. The class is instantiated with the YAML `config` dict as kwargs.

## Configuration Reference

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `adapters` | `list[dict] \| null` | `null` | Ordered interceptor specs on each server config. Each entry is `{name: <str>, config: <dict>}`. `null` or `[]` disables the middleware. |
| `adapter_proxy` | `AdapterProxyConfig \| null` | `null` | On `BaseResponsesAPIAgentConfig` only. Configures a localhost proxy in front of an external inference upstream. Fields: `upstream_url`, `adapters`, `host` (default `127.0.0.1`), `port` (default `0` → kernel-assigned), `request_timeout`, `unsafe_allow_remote`. |

<Warning>
`start_adapter_proxy` refuses any `host` other than `127.0.0.1`/`localhost` unless you pass `unsafe_allow_remote=True`. The proxy forwards the client's `Authorization` header verbatim to the upstream, so binding to `0.0.0.0` would leak the upstream API key to any caller on the network.
</Warning>
6 changes: 6 additions & 0 deletions fern/versions/latest/pages/model-server/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,9 @@ Self-hosted inference with vLLM for maximum control.
[Model Server Fields](/reference/configuration#model-server-fields) for server configuration syntax and fields.

</Note>

## Middleware

Model servers can attach an interceptor chain that observes or mutates request and response payloads — useful for logging, caching, system-prompt injection, turn budgeting, reasoning-field normalization, and similar cross-cutting hooks.

See [Adapter Middleware](/model-server/adapters) for the framework, built-in interceptors, and configuration syntax.
58 changes: 58 additions & 0 deletions nemo_gym/adapters/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Gym adapter framework — interceptor-based middleware for responses_api_models.

Public API:
install_middleware(app, interceptor_specs) — attach pipeline to a FastAPI app
start_adapter_proxy(upstream_url, adapters) — host pipeline as a localhost uvicorn
AdapterPipeline — the async interceptor chain
InterceptorRegistry — name → class resolution
"""

from nemo_gym.adapters.middleware import install_middleware
from nemo_gym.adapters.pipeline import AdapterPipeline
from nemo_gym.adapters.proxy import ProxyHandle, start_adapter_proxy
from nemo_gym.adapters.registry import InterceptorRegistry
from nemo_gym.adapters.types import (
AdapterProxyConfig,
AdapterRequest,
AdapterResponse,
GracefulError,
InterceptorContext,
InterceptorSpec,
RequestInterceptor,
RequestToResponseInterceptor,
ResponseInterceptor,
Stage,
)


__all__ = [
"AdapterPipeline",
"AdapterProxyConfig",
"AdapterRequest",
"AdapterResponse",
"GracefulError",
"InterceptorContext",
"InterceptorRegistry",
"InterceptorSpec",
"ProxyHandle",
"RequestInterceptor",
"RequestToResponseInterceptor",
"ResponseInterceptor",
"Stage",
"install_middleware",
"start_adapter_proxy",
]
15 changes: 15 additions & 0 deletions nemo_gym/adapters/interceptors/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Built-in interceptors for the adapter pipeline."""
100 changes: 100 additions & 0 deletions nemo_gym/adapters/interceptors/consolidate_system.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Merge all system messages into one at position 0.

Models like Qwen3 reject system messages that appear mid-conversation
(``System message must be at the beginning``).
"""

from __future__ import annotations

import logging

from nemo_gym.adapters.types import AdapterRequest, RequestInterceptor


logger = logging.getLogger(__name__)


def _content_to_str(content) -> str:
"""Extract plain text from either a string or OpenAI list-format content."""
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for item in content:
if isinstance(item, dict):
parts.append(item.get("text", ""))
elif isinstance(item, str):
parts.append(item)
return "\n".join(parts)
return str(content) if content else ""


class Interceptor(RequestInterceptor):
def __init__(self, *, separator: str = "\n\n") -> None:
self._sep = separator
self._fix_count = 0

async def intercept_request(self, req: AdapterRequest) -> AdapterRequest:
messages: list[dict] = req.body.get("messages", [])
if not messages:
return req

system_indices: list[int] = []
system_parts: list[str] = []
non_system: list[dict] = []
for i, msg in enumerate(messages):
if msg.get("role") == "system":
system_indices.append(i)
text = _content_to_str(msg.get("content", ""))
if text:
system_parts.append(text)
else:
non_system.append(msg)

if len(system_indices) <= 1 and (not system_indices or system_indices[0] == 0):
return req

self._fix_count += 1
session = req.ctx.extra.get("session_id", "?")

role_seq = [m.get("role", "?") for m in messages]
displaced = [i for i in system_indices if i > 0]
diag_parts: list[str] = []
for idx in displaced:
lo = max(0, idx - 2)
hi = min(len(role_seq), idx + 3)
window = " ".join(f"[{j}]={role_seq[j]}" for j in range(lo, hi))
content_preview = _content_to_str(messages[idx].get("content", ""))[:300]
diag_parts.append(f"system@{idx} window=({window}) content_preview={content_preview!r}")

logger.warning(
"consolidate_system: fix #%d session=%s n_msgs=%d system_at=%s n_system=%d roles_head=%s | %s",
self._fix_count,
session,
len(messages),
system_indices,
len(system_parts),
role_seq[:8],
" | ".join(diag_parts) if diag_parts else "system missing from pos 0",
)

merged: list[dict] = []
if system_parts:
merged.append({"role": "system", "content": self._sep.join(system_parts)})
merged.extend(non_system)
req.body["messages"] = merged
return req
38 changes: 38 additions & 0 deletions nemo_gym/adapters/interceptors/drop_params.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations

import logging

from nemo_gym.adapters.types import AdapterRequest, RequestInterceptor


logger = logging.getLogger(__name__)


class Interceptor(RequestInterceptor):
def __init__(self, *, params: list[str]) -> None:
self._params = set(params)
self._logged_once = False

async def intercept_request(self, req: AdapterRequest) -> AdapterRequest:
for p in self._params:
req.body.pop(p, None)

if not self._logged_once:
logger.info("drop_params: removing %s", sorted(self._params))
self._logged_once = True

return req
Loading
Loading