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
26 changes: 26 additions & 0 deletions fern/versions/latest/pages/model-server/adapters-caching.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
title: "Caching Interceptor"
description: "Disk-backed cache that short-circuits the chain on cache hit."
position: 5
---

Disk-backed cache keyed by a SHA-256 hash of the canonicalized request body (+ optional session prefix). On cache hit, the interceptor short-circuits the chain and returns the stored response without invoking the upstream.

| Name | Stage | Purpose |
|------|-------|---------|
| `caching` | request → response | Disk-backed cache (sqlite). Hits short-circuit upstream. |

### `caching`

```yaml
- name: caching
config:
cache_dir: /var/cache/gym-adapter
bypass: false
```

Cache keys include the session prefix when present (via `ctx.extra["session_id"]`), so the same body in different sessions does not collide.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

adapters-caching.mdx:22

This line notes keys are session-scoped "when present (via ctx.extra["session_id"])" — I wanted to check how that plays out in proxy mode. It looks like session_id is only set from the /s/<hex>/ path in middleware mode, and the proxy builds a bare context, so in the bring-your-own-inference path the prefix would always be empty. If that's right, then with temperature > 0 / no seed, repeated samples of the same prompt would share one cache entry and collapse sampling diversity. Could we add a short caveat that proxy mode currently has no session isolation, and that caching is best used with deterministic decoding (or a distinct session_id per sample) to avoid silently de-duplicating independent samples?


<Note>
Per-replica proxy or server instance means per-replica disk cache: if multiple replicas share the same `cache_dir`, sqlite write contention is real. Configure a per-replica path (e.g. `cache_dir: /tmp/adapter_cache_${pod_id}`) or accept the race.
</Note>
14 changes: 14 additions & 0 deletions nemo_gym/adapters/cache/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# 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.
112 changes: 112 additions & 0 deletions nemo_gym/adapters/cache/disk_cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# 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 asyncio
import hashlib
import json
import logging
import os
import sqlite3
import threading
import time
from typing import Any


logger = logging.getLogger(__name__)

_RELEVANT_KEYS = ("model", "messages", "tools", "temperature", "max_tokens", "top_p", "seed")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

disk_cache.py:30

Trying to understand the intended scope of the cache key. _RELEVANT_KEYS covers model/messages/tools/temperature/max_tokens/top_p/seed (+ extra_body), but several other output-affecting params aren't included — so two requests differing only in one of them would hash to the same key and the second would get the first's cached response. A few cases I wasn't sure how you'd want handled:

  • response_format — would a {"type": "json_object"} call collide with a prior free-text call on the same prompt, and hand the verifier back un-structured output?
  • n — does an n: 1 entry get served to a later n: 8 (pass@k) request, collapsing it to a single choice?
  • stop — would an agent's stop: ["\n\nObservation:"] call collide with a no-stop call, so the cached body runs past the boundary the harness expects?
  • logprobs / tool_choice — similar story for GenRM/logprob scoring and forced-tool calls?

Is the intent that callers always route these through extra_body (which is keyed), or should the allowlist be expanded — or the key derived from the full canonical body minus a small denylist of volatile fields? Mostly want to make sure the cache can't silently return a wrong-shaped response in the eval/RL paths.


_SCHEMA = """
CREATE TABLE IF NOT EXISTS cache_v1 (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
created_at REAL NOT NULL
);
"""

_UPSERT = """
INSERT INTO cache_v1 (key, value, created_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, created_at=excluded.created_at;
"""


class DiskCache:
def __init__(self, cache_dir: str) -> None:
os.makedirs(cache_dir, exist_ok=True)
self._db_path = os.path.join(cache_dir, "adapter_cache.db")
self._lock = threading.Lock()
self._init_db()

def _init_db(self) -> None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

disk_cache.py:53

Small one: busy_timeout is set in _init_db, but since it's per-connection (WAL persists on the file, busy_timeout doesn't), the fresh connections in _get_sync/_set_sync would use the default of 0. In the shared-cache_dir scenario the docs mention, that could turn a transient lock into a silently-dropped write. Would it be worth re-applying PRAGMA busy_timeout=5000 on each connection (or factoring a small _connect() helper) so the 5s wait applies to reads/writes too?

try:
with self._lock:
conn = sqlite3.connect(self._db_path)
try:
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
conn.execute(_SCHEMA)
conn.commit()
finally:
conn.close()
except sqlite3.Error:
logger.warning("Failed to initialize cache DB at %s", self._db_path, exc_info=True)

@staticmethod
def cache_key(body: dict[str, Any], *, session_prefix: str = "") -> str:
canonical: dict[str, Any] = {}
for k in _RELEVANT_KEYS:
if k in body:
canonical[k] = body[k]
if "extra_body" in body and isinstance(body["extra_body"], dict):
canonical["extra_body"] = body["extra_body"]
raw = json.dumps(canonical, sort_keys=True, ensure_ascii=False)
if session_prefix:
raw = session_prefix + "|" + raw
return hashlib.sha256(raw.encode()).hexdigest()

def _get_sync(self, key: str) -> dict | None:
try:
with self._lock:
conn = sqlite3.connect(self._db_path)
try:
row = conn.execute("SELECT value FROM cache_v1 WHERE key = ?", (key,)).fetchone()
finally:
conn.close()
if row is None:
return None
return json.loads(row[0])
except (sqlite3.Error, json.JSONDecodeError):
logger.warning("Cache get failed for key=%s", key[:16], exc_info=True)
return None

def _set_sync(self, key: str, value: dict) -> None:
try:
serialized = json.dumps(value, ensure_ascii=False)
with self._lock:
conn = sqlite3.connect(self._db_path)
try:
conn.execute(_UPSERT, (key, serialized, time.time()))
conn.commit()
finally:
conn.close()
except sqlite3.Error:
logger.warning("Cache set failed for key=%s", key[:16], exc_info=True)

async def get(self, key: str) -> dict | None:
return await asyncio.to_thread(self._get_sync, key)

async def set(self, key: str, value: dict) -> None:
await asyncio.to_thread(self._set_sync, key, value)
67 changes: 67 additions & 0 deletions nemo_gym/adapters/interceptors/caching.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# 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.cache.disk_cache import DiskCache
from nemo_gym.adapters.types import (
AdapterRequest,
AdapterResponse,
RequestToResponseInterceptor,
ResponseInterceptor,
)


logger = logging.getLogger(__name__)


class Interceptor(RequestToResponseInterceptor, ResponseInterceptor):
stream_safe = False
Comment on lines +31 to +32

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

caching.py:31

Optional nit: since the hit-short-circuit / miss-write-back contract is a little subtle, a short docstring on the public surface might help future readers:

Suggested change
class Interceptor(RequestToResponseInterceptor, ResponseInterceptor):
stream_safe = False
class Interceptor(RequestToResponseInterceptor, ResponseInterceptor):
"""SHA-256-keyed disk cache. On hit, short-circuits the chain with the
stored response; on miss, records the cache key so the matching response
phase writes the upstream body back. Keys are session-scoped when
``ctx.extra["session_id"]`` is set. ``stream_safe = False`` is declared
but not yet enforced by the pipeline (see streaming note)."""
stream_safe = False

(Same idea for DiskCache / cache_key / get / set in disk_cache.py if you agree it's worth it.)


def __init__(self, cache_dir: str, *, bypass: bool = False) -> None:
self._bypass = bypass
self._cache = DiskCache(cache_dir)

async def intercept_request(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

caching.py:38

How does this interceptor expect to interact with streaming requests? I see stream_safe = False declared, but I couldn't find where the pipeline reads that flag (it looks inert for now), and stream isn't part of the cache key. So it seems like a {"stream": true} request could take a cache hit and be returned a JSON body instead of an SSE stream — would that break the client's stream parser? Would it make sense to short-circuit early here (e.g. if req.body.get("stream"): return req) until the pipeline honors stream_safe, and flip the cache_key(stream=True) == cache_key(stream=False) assertion accordingly?

self,
req: AdapterRequest,
) -> AdapterRequest | AdapterResponse:
if self._bypass:
return req
session_prefix = req.ctx.extra.get("session_id", "")
key = DiskCache.cache_key(req.body, session_prefix=session_prefix)
hit = await self._cache.get(key)
if hit is not None:
logger.debug("cache hit key=%s", key[:16])
req.ctx.extra["cache_hit"] = True
return AdapterResponse(
status_code=200,
headers={},
body=hit,
latency_ms=0.0,
ctx=req.ctx,
)
req.ctx.extra["cache_key"] = key
return req

async def intercept_response(self, resp: AdapterResponse) -> AdapterResponse:
key = resp.ctx.extra.get("cache_key")
if key is None or not resp.ok:
return resp
if isinstance(resp.body, dict):
await self._cache.set(key, resp.body)
resp.ctx.extra.pop("cache_key", None)
return resp
3 changes: 3 additions & 0 deletions nemo_gym/adapters/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@
# Each family adds entries under its own family-named comment marker so
# different families don't fight for the same diff context.

# Caching family.
_BUILTIN["caching"] = "nemo_gym.adapters.interceptors.caching"

# External / plugin registrations at runtime.
_EXTRA: dict[str, str] = {}

Expand Down
68 changes: 68 additions & 0 deletions tests/unit_tests/test_adapter_cache_keys.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# 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.
"""Cache-key derivation golden tests — ported from NEL.

The golden SHA-256 strings are byte-identical to NEL's: the cache_key
algorithm is the same code re-rooted at ``nemo_gym.adapters.cache``, so
both implementations must produce the same digests for the same inputs.
This file protects against accidental drift.
"""

from nemo_gym.adapters.cache.disk_cache import DiskCache


def _compute_key(body):
return DiskCache.cache_key(body)


def test_golden_key_simple():
body = {"model": "gpt-4", "messages": [{"role": "user", "content": "hello"}], "temperature": 0.7}
key = _compute_key(body)
assert key == _compute_key(body)
assert key == "92232807c9b5c0bab68fd1abb38bc83c884fb04b0eb9b8b453c19daead7d682f" # pragma: allowlist secret


def test_golden_key_with_tools():
body = {
"model": "gpt-4",
"messages": [{"role": "user", "content": "hello"}],
"tools": [{"type": "function", "function": {"name": "get_weather"}}],
}
key = _compute_key(body)
assert key == _compute_key(body)
assert key == "ce43d524c627c96eaadbb9abdac38ad5eb60a93d15b1d26880fa1fe0f86d4c68" # pragma: allowlist secret


def test_key_ignores_irrelevant_fields():
base = {"model": "gpt-4", "messages": [{"role": "user", "content": "hello"}]}
with_stream = {**base, "stream": True}
assert _compute_key(base) == _compute_key(with_stream)


def test_key_changes_with_temperature():
body_a = {"model": "gpt-4", "messages": [{"role": "user", "content": "hello"}], "temperature": 0.7}
body_b = {"model": "gpt-4", "messages": [{"role": "user", "content": "hello"}], "temperature": 0.9}
assert _compute_key(body_a) != _compute_key(body_b)


def test_key_differs_with_session_prefix():
"""Session prefix ensures repeats of the same problem never share cache entries."""
body = {"model": "gpt-4", "messages": [{"role": "user", "content": "hello"}]}
k_none = DiskCache.cache_key(body)
k_a = DiskCache.cache_key(body, session_prefix="repeat-0")
k_b = DiskCache.cache_key(body, session_prefix="repeat-1")
assert k_none != k_a
assert k_a != k_b
assert k_none == DiskCache.cache_key(body, session_prefix="")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test_adapter_cache_keys.py:68

The extra_body branch of cache_key (dict → folded into the key, non-dict → ignored) doesn't look covered yet. Since extra_body is the one "extra" field that does affect the key, pinning it with a test also documents that contract. Both pass locally — the suggestion appends two module-level tests:

Suggested change
assert k_none == DiskCache.cache_key(body, session_prefix="")
assert k_none == DiskCache.cache_key(body, session_prefix="")
def test_key_includes_extra_body():
base = {"model": "gpt-4", "messages": [{"role": "user", "content": "hi"}]}
with_extra = {**base, "extra_body": {"guided_json": {"type": "object"}}}
assert _compute_key(base) != _compute_key(with_extra)
def test_key_ignores_non_dict_extra_body():
base = {"model": "gpt-4", "messages": [{"role": "user", "content": "hi"}]}
with_bad = {**base, "extra_body": "not-a-dict"}
assert _compute_key(base) == _compute_key(with_bad)

68 changes: 68 additions & 0 deletions tests/unit_tests/test_adapter_disk_cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# 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.
"""DiskCache behavior tests — ported from NEL."""

import asyncio

import pytest

from nemo_gym.adapters.cache.disk_cache import DiskCache


@pytest.fixture
def cache(tmp_path):
return DiskCache(str(tmp_path / "cache"))


def test_cache_key_deterministic():
body = {"model": "gpt-4", "messages": [{"role": "user", "content": "hello"}]}
assert DiskCache.cache_key(body) == DiskCache.cache_key(body)


def test_cache_key_varies_with_model():
base = {"model": "gpt-4", "messages": [{"role": "user", "content": "hello"}]}
other = {**base, "model": "gpt-3.5-turbo"}
assert DiskCache.cache_key(base) != DiskCache.cache_key(other)


async def test_get_set_roundtrip(cache):
key = DiskCache.cache_key({"model": "x", "messages": []})
value = {"choices": [{"message": {"content": "hi"}}]}
await cache.set(key, value)
result = await cache.get(key)
assert result == value


async def test_get_missing_returns_none(cache):
result = await cache.get("nonexistent_key")
assert result is None


async def test_concurrent_writes(cache):
pairs = [(f"key_{i}", {"i": i}) for i in range(50)]

await asyncio.gather(*(cache.set(k, v) for k, v in pairs))

for k, v in pairs:
assert await cache.get(k) == v


async def test_degrade_on_bad_path():
bad = DiskCache.__new__(DiskCache)
bad._db_path = "/dev/null/bad/adapter_cache.db"
bad._lock = __import__("threading").Lock()

assert await bad.get("anything") is None
await bad.set("anything", {"x": 1})
Loading
Loading