Skip to content

Commit 17520f9

Browse files
feat(response-cache): opt-in exact-match LLM response cache
An opt-in feature of the adaptive plugin (a response_cache config section, not a new plugin kind): managed LLM calls are keyed by a SHA-256 fingerprint of the normalized request and repeats are served from the store — instant, free, reproducible. Buffered and streaming calls share one keyspace; a streamed miss is teed and stored as its aggregated response, a hit replays provider-native chunks. Keying auto-detects the provider surface from the request shape and trusts the decode only where it is faithful: known-lossy shapes and decodes that fail to round-trip fall back to raw-body fingerprinting, which can only cost a miss. Stateful calls (Responses persistence, conversations, containers) and nondeterministic calls under the safety toggle bypass entirely. Stored answers must be complete: non-null errors, non-final statuses, truncated or lossily-collected streams, and upstream failures are never cached — the CLI gateway relays failed upstream replies to the client verbatim while keeping them invisible to the execution chain. The in-memory store is bounded by an honest resident-size budget with oldest-first eviction; the Redis backend runs under hard deadlines and re-checks entry expiry. Everything fails open: any cache error falls through to a live call. Same config surface in Rust, Python, Node, and Go; doctor reports configuration and backend reachability; hit/miss/ bypass marks carry fingerprints and savings, never bodies. Signed-off-by: Zhongxuan Wang <daniewang@nvidia.com>
1 parent 95af285 commit 17520f9

30 files changed

Lines changed: 5652 additions & 28 deletions

.gitignore

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ integrations/openclaw/.test-dist/
4444
go/nemo_relay/coverage.out
4545
go_junit_report.xml
4646
*.dSYM/
47+
# Runtime observability capture files (written by examples/tests)
48+
nemo-relay-events-*.jsonl
4749

4850
# OS — macOS
4951
.DS_Store
@@ -85,3 +87,12 @@ CHANGELOG.md
8587

8688
# Relay
8789
/.nemo-relay
90+
91+
# Local response-cache scratch files (proposals/, demos, benchmarks, quickstarts),
92+
# kept on disk for local use only, never committed.
93+
/proposals/
94+
crates/adaptive/examples/response_cache_*.rs
95+
python/examples/response_cache_*.py
96+
97+
# Local-only response-cache notes (kept local, never committed)
98+
RESPONSE_CACHE*.md

crates/adaptive/Cargo.toml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ chrono = { version = "0.4", features = ["serde"] }
2222
thiserror = "2"
2323
tdigest = { version = "0.2", features = ["use_serde"] }
2424
tokio = { version = "1", features = ["rt", "macros", "sync", "time"] }
25+
tokio-stream = { version = "0.1", default-features = false, features = ["sync"] }
2526
regex = "1"
2627
serde_json_canonicalizer = "0.3"
2728
sha2 = "0.11"
@@ -33,7 +34,6 @@ redis-backend = ["redis"]
3334

3435
[dev-dependencies]
3536
tokio = { version = "1", default-features = false, features = ["rt", "macros", "sync", "time", "test-util", "rt-multi-thread"] }
36-
tokio-stream = { version = "0.1", default-features = false }
3737

3838
[[test]]
3939
name = "redis_integration"
@@ -46,3 +46,11 @@ path = "tests/integration/runtime_integration_tests.rs"
4646
[[test]]
4747
name = "acg_module_surface"
4848
path = "tests/integration/acg_module_surface_tests.rs"
49+
50+
[[test]]
51+
name = "response_cache_integration"
52+
path = "tests/integration/response_cache_tests.rs"
53+
54+
[[test]]
55+
name = "response_cache_benchmark_integration"
56+
path = "tests/integration/response_cache_benchmark_tests.rs"

crates/adaptive/src/config.rs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ use nemo_relay::plugin::ConfigPolicy;
77
use serde::{Deserialize, Serialize};
88
use serde_json::{Map, Value as Json};
99

10+
use crate::response_cache::config::{BackendConfig, KEY_STRATEGY_EXACT_REQUEST};
11+
1012
/// Canonical config document for the adaptive plugin component.
1113
#[derive(Debug, Clone, Serialize, Deserialize)]
1214
pub struct AdaptiveConfig {
@@ -32,6 +34,10 @@ pub struct AdaptiveConfig {
3234
/// Adaptive Cache Governor settings.
3335
#[serde(default, skip_serializing_if = "Option::is_none")]
3436
pub acg: Option<AcgComponentConfig>,
37+
/// Opt-in LLM response cache (exact-match). When present, the
38+
/// adaptive plugin installs the response-cache execution intercept(s).
39+
#[serde(default, skip_serializing_if = "Option::is_none")]
40+
pub response_cache: Option<ResponseCacheConfig>,
3541
/// Adaptive-local unsupported-config policy.
3642
#[serde(default)]
3743
pub policy: ConfigPolicy,
@@ -47,6 +53,7 @@ impl Default for AdaptiveConfig {
4753
adaptive_hints: None,
4854
tool_parallelism: None,
4955
acg: None,
56+
response_cache: None,
5057
policy: ConfigPolicy::default(),
5158
}
5259
}
@@ -184,6 +191,55 @@ impl Default for AcgComponentConfig {
184191
}
185192
}
186193

194+
/// Configuration for the adaptive plugin's `response_cache` feature
195+
#[derive(Debug, Clone, Serialize, Deserialize)]
196+
#[serde(default)]
197+
pub struct ResponseCacheConfig {
198+
/// How long a stored answer stays reusable, in seconds.
199+
pub ttl_seconds: u64,
200+
/// Namespace folded into every key to separate environments/tenants.
201+
pub namespace: String,
202+
/// Execution-intercept priority; lower runs first/outermost (default `50`).
203+
pub priority: i32,
204+
/// Probability in `[0.0, 1.0]` of skipping the cache and running live.
205+
pub bypass_rate: f64,
206+
/// Cache nondeterministic requests too. Set `false` to cache only
207+
/// requests explicitly pinned deterministic (`temperature` = 0) — absent
208+
/// or unreadable temperatures count as nondeterministic.
209+
pub cache_nondeterministic: bool,
210+
/// Key strategy. Only [`KEY_STRATEGY_EXACT_REQUEST`] is supported.
211+
pub key_strategy: String,
212+
/// Request headers (case-insensitive) folded into the key; never auth headers.
213+
pub header_allowlist: Vec<String>,
214+
/// Extra top-level request-body keys to drop from the key, beyond the noise defaults.
215+
pub skip_keys: Vec<String>,
216+
/// Storage backend selection.
217+
pub backend: BackendConfig,
218+
}
219+
220+
impl Default for ResponseCacheConfig {
221+
fn default() -> Self {
222+
Self {
223+
ttl_seconds: 3600,
224+
namespace: String::new(),
225+
priority: 50,
226+
bypass_rate: 0.0,
227+
cache_nondeterministic: true,
228+
key_strategy: KEY_STRATEGY_EXACT_REQUEST.to_string(),
229+
header_allowlist: Vec::new(),
230+
skip_keys: Vec::new(),
231+
backend: BackendConfig::default(),
232+
}
233+
}
234+
}
235+
236+
impl ResponseCacheConfig {
237+
/// TTL as a [`std::time::Duration`].
238+
pub fn ttl(&self) -> std::time::Duration {
239+
std::time::Duration::from_secs(self.ttl_seconds)
240+
}
241+
}
242+
187243
fn default_adaptive_config_version() -> u32 {
188244
1
189245
}
@@ -254,6 +310,13 @@ nemo_relay::editor_config! {
254310
nested: AcgComponentConfig,
255311
default: AcgComponentConfig,
256312
},
313+
response_cache => {
314+
label: "response_cache",
315+
kind: Section,
316+
optional: true,
317+
nested: ResponseCacheConfig,
318+
default: ResponseCacheConfig,
319+
},
257320
policy => {
258321
label: "policy",
259322
kind: Section,
@@ -326,6 +389,25 @@ nemo_relay::editor_config! {
326389
}
327390
}
328391

392+
nemo_relay::editor_config! {
393+
impl ResponseCacheConfig {
394+
ttl_seconds => { label: "ttl_seconds", kind: Integer },
395+
namespace => { label: "namespace", kind: String },
396+
priority => { label: "priority", kind: Integer },
397+
bypass_rate => { label: "bypass_rate", kind: Float },
398+
cache_nondeterministic => { label: "cache_nondeterministic", kind: Boolean },
399+
key_strategy => { label: "key_strategy", kind: String },
400+
header_allowlist => { label: "header_allowlist", kind: Json },
401+
skip_keys => { label: "skip_keys", kind: Json },
402+
backend => {
403+
label: "backend",
404+
kind: Section,
405+
nested: BackendConfig,
406+
default: BackendConfig,
407+
},
408+
}
409+
}
410+
329411
nemo_relay::editor_config! {
330412
impl crate::acg::stability::StabilityThresholds {
331413
stable_threshold => { label: "stable_threshold", kind: Float },

crates/adaptive/src/lib.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ pub mod learner;
3030
pub mod plugin_component;
3131
#[cfg(feature = "redis-backend")]
3232
pub mod redis;
33+
/// Opt-in LLM response cache (exact-match).
34+
pub mod response_cache;
3335
mod runtime;
3436
/// Storage backends and backend traits for adaptive state persistence.
3537
pub mod storage;
@@ -41,8 +43,8 @@ pub mod trie;
4143
pub mod types;
4244

4345
pub use config::{
44-
AcgComponentConfig, AdaptiveConfig, AdaptiveHintsComponentConfig, BackendSpec, StateConfig,
45-
TelemetryComponentConfig, ToolParallelismComponentConfig,
46+
AcgComponentConfig, AdaptiveConfig, AdaptiveHintsComponentConfig, BackendSpec,
47+
ResponseCacheConfig, StateConfig, TelemetryComponentConfig, ToolParallelismComponentConfig,
4648
};
4749
pub use context_helpers::{
4850
LATENCY_SENSITIVITY_POINTER, extract_scope_path, read_manual_latency_sensitivity,
@@ -51,6 +53,7 @@ pub use context_helpers::{
5153
pub use error::{AdaptiveError, Result};
5254
#[cfg(feature = "redis-backend")]
5355
pub use redis::RedisBackend;
56+
pub use response_cache::RESPONSE_CACHE_MARK;
5457
pub use runtime::features::AdaptiveRuntime;
5558
pub use storage::erased::AnyBackend;
5659
pub use storage::memory::InMemoryBackend;

crates/adaptive/src/plugin_component.rs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ fn validate_adaptive_plugin_config(plugin_config: &Map<String, Json>) -> Vec<Con
182182
"adaptive_hints",
183183
"tool_parallelism",
184184
"acg",
185+
"response_cache",
185186
"policy",
186187
],
187188
);
@@ -284,10 +285,75 @@ fn validate_adaptive_plugin_config(plugin_config: &Map<String, Json>) -> Vec<Con
284285
);
285286
}
286287

288+
if let Some(response_cache_json) = plugin_config
289+
.get("response_cache")
290+
.and_then(Json::as_object)
291+
{
292+
validate_unknown_fields(
293+
&mut diagnostics,
294+
&config.policy,
295+
Some("response_cache".to_string()),
296+
response_cache_json,
297+
&[
298+
"ttl_seconds",
299+
"namespace",
300+
"priority",
301+
"bypass_rate",
302+
"cache_nondeterministic",
303+
"key_strategy",
304+
"header_allowlist",
305+
"skip_keys",
306+
"backend",
307+
],
308+
);
309+
if let Some(backend_json) = response_cache_json.get("backend").and_then(Json::as_object) {
310+
validate_unknown_fields(
311+
&mut diagnostics,
312+
&config.policy,
313+
Some("response_cache.backend".to_string()),
314+
backend_json,
315+
&["kind", "config"],
316+
);
317+
let backend_kind = backend_json
318+
.get("kind")
319+
.and_then(Json::as_str)
320+
.unwrap_or_default();
321+
if let Some(backend_config_json) = backend_json.get("config").and_then(Json::as_object)
322+
{
323+
validate_response_cache_backend_config_fields(
324+
&mut diagnostics,
325+
&config.policy,
326+
backend_kind,
327+
backend_config_json,
328+
);
329+
}
330+
}
331+
}
332+
287333
diagnostics.extend(AdaptiveRuntime::validate_config(&config).diagnostics);
288334
diagnostics
289335
}
290336

337+
fn validate_response_cache_backend_config_fields(
338+
diagnostics: &mut Vec<ConfigDiagnostic>,
339+
policy: &ConfigPolicy,
340+
backend_kind: &str,
341+
backend_config: &Map<String, Json>,
342+
) {
343+
let known_fields: &[&str] = match backend_kind {
344+
"in_memory" => &["max_bytes", "max_entries"],
345+
"redis" => &["url", "key_prefix"],
346+
_ => return,
347+
};
348+
validate_unknown_fields(
349+
diagnostics,
350+
policy,
351+
Some(format!("response_cache.backend.{backend_kind}")),
352+
backend_config,
353+
known_fields,
354+
);
355+
}
356+
291357
fn validate_backend_config_fields(
292358
diagnostics: &mut Vec<ConfigDiagnostic>,
293359
policy: &ConfigPolicy,
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//! Backend selection for the adaptive plugin's `response_cache` feature.
5+
//!
6+
//! The `response_cache` section struct ([`crate::config::ResponseCacheConfig`])
7+
//! lives in [`crate::config`] alongside the other `AdaptiveConfig` sections
8+
//! (`acg`, `adaptive_hints`, `tool_parallelism`). This module keeps the
9+
//! response-cache-specific backend config and the key-strategy constant next to
10+
//! the key/store code that consumes them.
11+
12+
use serde::{Deserialize, Serialize};
13+
use serde_json::{Map, Value as Json};
14+
15+
/// Exact-request key strategy identifier.
16+
pub const KEY_STRATEGY_EXACT_REQUEST: &str = "exact_request";
17+
18+
/// Default in-memory byte budget: 256 MiB.
19+
pub const DEFAULT_MAX_BYTES: usize = 256 * 1024 * 1024;
20+
21+
/// Backend selection mirroring the adaptive [`crate::config::BackendSpec`] shape.
22+
#[derive(Debug, Clone, Serialize, Deserialize)]
23+
#[serde(default)]
24+
pub struct BackendConfig {
25+
/// Backend kind: `"in_memory"` or `"redis"` (needs the `redis-backend` feature).
26+
pub kind: String,
27+
/// Backend-specific options (in_memory: `max_bytes`/`max_entries`; redis: `url`/`key_prefix`).
28+
pub config: Map<String, Json>,
29+
}
30+
31+
impl Default for BackendConfig {
32+
fn default() -> Self {
33+
Self {
34+
kind: "in_memory".to_string(),
35+
config: Map::new(),
36+
}
37+
}
38+
}
39+
40+
impl BackendConfig {
41+
/// In-memory total-bytes budget before oldest-first eviction.
42+
pub fn max_bytes(&self) -> usize {
43+
self.config
44+
.get("max_bytes")
45+
.and_then(Json::as_u64)
46+
.map(|value| value as usize)
47+
.unwrap_or(DEFAULT_MAX_BYTES)
48+
}
49+
50+
/// Optional in-memory entry-count cap (on top of the byte budget).
51+
pub fn max_entries(&self) -> Option<usize> {
52+
self.config
53+
.get("max_entries")
54+
.and_then(Json::as_u64)
55+
.map(|value| value as usize)
56+
}
57+
}
58+
59+
nemo_relay::editor_config! {
60+
impl BackendConfig {
61+
kind => { label: "kind", kind: Enum, values: ["in_memory", "redis"] },
62+
config => { label: "config", kind: Json },
63+
}
64+
}

0 commit comments

Comments
 (0)