Skip to content

Commit 31cffcf

Browse files
feat(adaptive): add logical response cache keys (#818)
#### Overview Adds a logical key strategy for LLM response caching so description-only edits and tool-definition reordering do not invalidate otherwise compatible cache entries. - [x] I confirm this contribution is my own work, or I have the right to submit it under this project's license. - [x] I searched existing issues and open pull requests, and this does not duplicate existing work. #### Details - Adds `key_strategy = "logical"`, which recursively removes string-valued tool-description fields and canonically sorts the request's tool definitions before keying. Tool names, parameter schemas, constraints, settings, and every non-tool request field remain key-significant. - Keeps `logical` and `exact_request` entries in separate keyspaces. - Exposes typed response-cache key strategy values in Rust, Python, Node.js, and Go, while preserving the existing string wire format and field-specific diagnostics for unknown values. - Adds unit and end-to-end coverage for description changes, tool ordering, interface changes, built-in tools, parameter enums, and strategy partitioning. - Documentation is tracked separately in #819. - Breaking changes: the typed Rust, Python, and Go `ResponseCacheConfig.key_strategy` helpers now use a strategy type instead of a plain string; the JSON/TOML wire values remain unchanged. The Node.js TypeScript surface narrows the field from `string` to the supported strategy union. - Validation: - `cargo fmt --all -- --check` - `cargo test -p nemo-relay-adaptive` - `just test-rust` - `cargo clippy --workspace --all-targets -- -D warnings` - `just test-python` (686 passed) - `just test-node` (391 passed) - `just test-go` - `uv run pre-commit run --all-files` - `uv run pre-commit run --files <changed code files>` #### Where should the reviewer start? Start with `build_cache_key` and `structural_tool_schema` in `crates/adaptive/src/response_cache/key.rs`, then review the logical-key cases in `crates/adaptive/tests/unit/response_cache/key_tests.rs`. #### Related Issues: (use one of the action keywords Closes / Fixes / Resolves / Relates to) - Relates to #597 - Relates to #598 - Relates to #819 ## Summary by CodeRabbit * **New Features** * Added a logical response-cache key strategy that reuses cached responses when tool descriptions or ordering change without affecting tool behavior. * Added typed key-strategy options across Rust, Node.js, Go, and Python integrations. * Added configuration support for `exact_request` and `logical`, with exact-request caching as the default. * **Bug Fixes** * Unsupported strategies now produce clearer validation errors listing supported options. * **Tests** * Expanded coverage for serialization, configuration, schema handling, and logical cache-key behavior. Authors: - Zhongxuan (Daniel) Wang (https://github.com/zhongxuanwang-nv) - Will Killian (https://github.com/willkill07) - Zhongxuan (Daniel) Wang (https://github.com/ZhongxuanWang) Approvers: - Will Killian (https://github.com/willkill07) URL: #818
1 parent 9063328 commit 31cffcf

17 files changed

Lines changed: 459 additions & 40 deletions

File tree

crates/adaptive/src/config.rs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ 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, ToolCacheConfig};
10+
use crate::response_cache::config::{BackendConfig, ResponseCacheKeyStrategy, ToolCacheConfig};
1111

1212
/// Canonical config document for the adaptive plugin component.
1313
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -34,7 +34,7 @@ pub struct AdaptiveConfig {
3434
/// Adaptive Cache Governor settings.
3535
#[serde(default, skip_serializing_if = "Option::is_none")]
3636
pub acg: Option<AcgComponentConfig>,
37-
/// Opt-in exact-match LLM response and tool-result cache. When present,
37+
/// Opt-in LLM response and tool-result cache. When present,
3838
/// the adaptive plugin installs the response-cache execution intercept(s).
3939
#[serde(default, skip_serializing_if = "Option::is_none")]
4040
pub response_cache: Option<ResponseCacheConfig>,
@@ -191,7 +191,7 @@ impl Default for AcgComponentConfig {
191191
}
192192
}
193193

194-
/// Configuration for the adaptive plugin's exact-match LLM response and
194+
/// Configuration for the adaptive plugin's LLM response and
195195
/// opt-in tool-result cache feature.
196196
#[derive(Debug, Clone, Serialize, Deserialize)]
197197
#[serde(default)]
@@ -212,8 +212,8 @@ pub struct ResponseCacheConfig {
212212
/// requests explicitly pinned deterministic (`temperature` = 0) — absent
213213
/// or unreadable temperatures count as nondeterministic.
214214
pub cache_nondeterministic: bool,
215-
/// Key strategy. Only [`KEY_STRATEGY_EXACT_REQUEST`] is supported.
216-
pub key_strategy: String,
215+
/// Typed key-derivation strategy.
216+
pub key_strategy: ResponseCacheKeyStrategy,
217217
/// Request headers (case-insensitive) folded into the key; never auth headers.
218218
pub header_allowlist: Vec<String>,
219219
/// Storage backend selection.
@@ -231,7 +231,7 @@ impl Default for ResponseCacheConfig {
231231
priority: 50,
232232
bypass_rate: 0.0,
233233
cache_nondeterministic: false,
234-
key_strategy: KEY_STRATEGY_EXACT_REQUEST.to_string(),
234+
key_strategy: ResponseCacheKeyStrategy::ExactRequest,
235235
header_allowlist: Vec::new(),
236236
backend: BackendConfig::default(),
237237
tools: None,
@@ -402,7 +402,7 @@ nemo_relay::editor_config! {
402402
priority => { label: "priority", kind: Integer },
403403
bypass_rate => { label: "bypass_rate", kind: Float },
404404
cache_nondeterministic => { label: "cache_nondeterministic", kind: Boolean },
405-
key_strategy => { label: "key_strategy", kind: String },
405+
key_strategy => { label: "key_strategy", kind: Enum, values: ["exact_request", "logical"] },
406406
header_allowlist => { label: "header_allowlist", kind: Json },
407407
backend => {
408408
label: "backend",

crates/adaptive/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,8 @@ pub use context_helpers::{
5757
pub use error::{AdaptiveError, Result};
5858
#[cfg(feature = "redis-backend")]
5959
pub use redis::RedisBackend;
60-
pub use response_cache::RESPONSE_CACHE_MARK;
6160
pub use response_cache::config::{ToolCacheConfig, ToolClass, ToolOverride};
61+
pub use response_cache::{RESPONSE_CACHE_MARK, ResponseCacheKeyStrategy};
6262
pub use runtime::features::AdaptiveRuntime;
6363
pub use storage::erased::AnyBackend;
6464
pub use storage::memory::InMemoryBackend;

crates/adaptive/src/response_cache/config.rs

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,69 @@
1111
1212
use std::collections::BTreeMap;
1313

14-
use serde::{Deserialize, Serialize};
14+
use serde::{Deserialize, Deserializer, Serialize, Serializer};
1515
use serde_json::{Map, Value as Json};
1616

17-
/// Exact-request key strategy identifier.
18-
pub const KEY_STRATEGY_EXACT_REQUEST: &str = "exact_request";
17+
/// Strategy for deriving an LLM response-cache key.
18+
///
19+
/// The `Unknown` variant preserves an unsupported JSON/TOML value long enough
20+
/// for configuration validation to report it with a field-specific diagnostic.
21+
#[derive(Debug, Clone, Default, PartialEq, Eq)]
22+
pub enum ResponseCacheKeyStrategy {
23+
/// Key on the normalized request exactly.
24+
#[default]
25+
ExactRequest,
26+
/// Normalize tool schemas structurally while preserving their interface.
27+
Logical,
28+
/// A wire value not supported by this Relay build.
29+
Unknown(String),
30+
}
31+
32+
impl ResponseCacheKeyStrategy {
33+
/// Stable JSON/TOML representation of this strategy.
34+
pub fn as_str(&self) -> &str {
35+
match self {
36+
Self::ExactRequest => "exact_request",
37+
Self::Logical => "logical",
38+
Self::Unknown(value) => value,
39+
}
40+
}
41+
}
42+
43+
impl From<&str> for ResponseCacheKeyStrategy {
44+
fn from(value: &str) -> Self {
45+
match value {
46+
"exact_request" => Self::ExactRequest,
47+
"logical" => Self::Logical,
48+
_ => Self::Unknown(value.to_string()),
49+
}
50+
}
51+
}
52+
53+
impl From<String> for ResponseCacheKeyStrategy {
54+
fn from(value: String) -> Self {
55+
Self::from(value.as_str())
56+
}
57+
}
58+
59+
impl Serialize for ResponseCacheKeyStrategy {
60+
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
61+
where
62+
S: Serializer,
63+
{
64+
serializer.serialize_str(self.as_str())
65+
}
66+
}
67+
68+
impl<'de> Deserialize<'de> for ResponseCacheKeyStrategy {
69+
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
70+
where
71+
D: Deserializer<'de>,
72+
{
73+
let value = String::deserialize(deserializer)?;
74+
Ok(Self::from(value))
75+
}
76+
}
1977

2078
/// Default in-memory byte budget: 256 MiB.
2179
pub const DEFAULT_MAX_BYTES: usize = 256 * 1024 * 1024;

crates/adaptive/src/response_cache/key.rs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ use serde_json::{Map, Value as Json, json};
2525
use sha2::{Digest, Sha256};
2626

2727
use crate::config::ResponseCacheConfig;
28+
use crate::response_cache::config::ResponseCacheKeyStrategy;
2829
use crate::response_cache::mark::CacheReason;
2930
use crate::response_cache::store::CACHE_SCHEMA_VERSION;
3031

@@ -77,6 +78,13 @@ pub fn build_cache_key(
7778
normalize_tool_call_ids(object);
7879
}
7980

81+
if config.key_strategy == ResponseCacheKeyStrategy::Logical
82+
&& let Some(object) = body.as_object_mut()
83+
&& let Some(tools) = object.get("tools").cloned()
84+
{
85+
object.insert("tools".to_string(), structural_tool_schema(&tools));
86+
}
87+
8088
let header_allowlist = normalized_header_allowlist(&config.header_allowlist);
8189
let headers = cache_key_headers(&request.headers, &header_allowlist);
8290

@@ -693,6 +701,48 @@ fn rewrite_id(id_value: &mut Json, mapping: &mut Map<String, Json>) {
693701
*id_value = Json::String(stable);
694702
}
695703

704+
/// Fingerprint of the tool set for the `logical` strategy: each tool keeps its
705+
/// full definition minus string-valued `description` keys (stripped
706+
/// recursively), and the array is sorted so tool order does not key.
707+
fn structural_tool_schema(tools: &Json) -> Json {
708+
let Some(array) = tools.as_array() else {
709+
return tools.clone();
710+
};
711+
let mut entries: Vec<Json> = array
712+
.iter()
713+
.map(|tool| {
714+
let mut entry = tool.clone();
715+
strip_descriptions(&mut entry);
716+
entry
717+
})
718+
.collect();
719+
entries
720+
.sort_by_cached_key(|entry| serde_json_canonicalizer::to_string(entry).unwrap_or_default());
721+
Json::Array(entries)
722+
}
723+
724+
/// Removes every string-valued `description` key, at any depth. A non-string
725+
/// value under that key (e.g. a schema property named `description`) is
726+
/// interface, not prose, and stays.
727+
fn strip_descriptions(value: &mut Json) {
728+
match value {
729+
Json::Object(object) => {
730+
if object.get("description").is_some_and(Json::is_string) {
731+
object.remove("description");
732+
}
733+
for nested in object.values_mut() {
734+
strip_descriptions(nested);
735+
}
736+
}
737+
Json::Array(items) => {
738+
for item in items {
739+
strip_descriptions(item);
740+
}
741+
}
742+
_ => {}
743+
}
744+
}
745+
696746
#[cfg(test)]
697747
#[path = "../../tests/unit/response_cache/key_tests.rs"]
698748
mod tests;

crates/adaptive/src/response_cache/mod.rs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
22
// SPDX-License-Identifier: Apache-2.0
33

4-
//! Opt-in exact-match cache for LLM responses and tool results: a feature of
4+
//! Opt-in cache for LLM responses and tool results: a feature of
55
//! the adaptive plugin, configured through
66
//! [`crate::config::AdaptiveConfig::response_cache`].
77
//!
@@ -23,9 +23,7 @@ pub mod store;
2323
pub(crate) mod tool;
2424

2525
pub use crate::config::ResponseCacheConfig;
26-
pub use crate::response_cache::config::{
27-
BackendConfig, KEY_STRATEGY_EXACT_REQUEST, ToolCacheConfig,
28-
};
26+
pub use crate::response_cache::config::{BackendConfig, ResponseCacheKeyStrategy, ToolCacheConfig};
2927
pub(crate) use crate::response_cache::intercept::{make_intercept, make_stream_intercept};
3028
pub use crate::response_cache::mark::RESPONSE_CACHE_MARK;
3129
pub(crate) use crate::response_cache::store::build_store;

crates/adaptive/src/runtime/validation.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use nemo_relay::plugin::{
99
use serde_json::Value as Json;
1010

1111
use crate::config::{AdaptiveConfig, BackendSpec, ResponseCacheConfig};
12-
use crate::response_cache::config::{KEY_STRATEGY_EXACT_REQUEST, ToolCacheConfig, ToolClass};
12+
use crate::response_cache::config::{ResponseCacheKeyStrategy, ToolCacheConfig, ToolClass};
1313
use crate::response_cache::tool::{is_supported_tool_pattern, wildcard_patterns_overlap};
1414

1515
pub fn validate_config(config: &AdaptiveConfig) -> ConfigReport {
@@ -123,11 +123,14 @@ fn validate_response_cache(report: &mut ConfigReport, config: &ResponseCacheConf
123123
"bypass_rate must be in [0.0, 1.0]".to_string(),
124124
));
125125
}
126-
if config.key_strategy != KEY_STRATEGY_EXACT_REQUEST {
126+
if matches!(config.key_strategy, ResponseCacheKeyStrategy::Unknown(_)) {
127127
report.diagnostics.push(response_cache_error(
128128
"response_cache.unsupported_key_strategy",
129129
Some("key_strategy"),
130-
format!("unsupported key_strategy; only \"{KEY_STRATEGY_EXACT_REQUEST}\" is supported"),
130+
format!(
131+
"unsupported key_strategy '{}'; supported: \"exact_request\", \"logical\"",
132+
config.key_strategy.as_str()
133+
),
131134
));
132135
}
133136
// Auth material must never enter the key or the stored entries.

crates/adaptive/tests/integration/response_cache_tests.rs

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ use nemo_relay::plugin::{
3333
};
3434
use nemo_relay_adaptive::plugin_component::{ComponentSpec, register_adaptive_component};
3535
use nemo_relay_adaptive::{
36-
AcgComponentConfig, AdaptiveConfig, BackendSpec, ResponseCacheConfig, StateConfig,
37-
ToolCacheConfig, ToolClass, ToolOverride,
36+
AcgComponentConfig, AdaptiveConfig, BackendSpec, ResponseCacheConfig, ResponseCacheKeyStrategy,
37+
StateConfig, ToolCacheConfig, ToolClass, ToolOverride,
3838
};
3939
use serde_json::{Value as Json, json};
4040
use tokio::sync::Mutex;
@@ -555,7 +555,7 @@ async fn invalid_config_is_rejected_by_validation() {
555555
response_cache: Some(ResponseCacheConfig {
556556
ttl_seconds: 0,
557557
bypass_rate: 2.0,
558-
key_strategy: "semantic".to_string(),
558+
key_strategy: ResponseCacheKeyStrategy::Unknown("semantic".to_string()),
559559
namespace: "invalid-config-test".to_string(),
560560
..ResponseCacheConfig::default()
561561
}),
@@ -647,7 +647,7 @@ async fn response_cache_validation_diagnostics_identify_the_invalid_setting() {
647647

648648
let mut cache = ResponseCacheConfig {
649649
namespace: "diagnostic-contract-test".to_string(),
650-
key_strategy: "semantic".to_string(),
650+
key_strategy: ResponseCacheKeyStrategy::Unknown("semantic".to_string()),
651651
tools: Some(ToolCacheConfig {
652652
enabled: true,
653653
default: ToolClass {
@@ -792,6 +792,50 @@ async fn hit_preserves_usage_on_the_end_event_and_reports_savings_on_the_mark()
792792
deregister_subscriber("response_cache_event_capture").unwrap();
793793
}
794794

795+
#[tokio::test]
796+
async fn logical_strategy_reuses_across_reworded_tool_descriptions() {
797+
let _guard = TEST_MUTEX.lock().await;
798+
reset_global();
799+
// `logical` must be accepted by validation (activate_cache asserts no
800+
// diagnostics) and must reuse across a reworded tool description end-to-end.
801+
activate_cache(ResponseCacheConfig {
802+
namespace: "logical-key-integration-test".to_string(),
803+
key_strategy: ResponseCacheKeyStrategy::Logical,
804+
..ResponseCacheConfig::default()
805+
})
806+
.await;
807+
808+
let calls = Arc::new(AtomicUsize::new(0));
809+
let provider = counting_provider(Arc::clone(&calls), sample_body());
810+
811+
let request_with_tool = |description: &str| LlmRequest {
812+
headers: serde_json::Map::new(),
813+
content: json!({
814+
"model": "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning",
815+
"messages": [{"role": "user", "content": "what is the weather?"}],
816+
"temperature": 0.0,
817+
"tools": [{"type": "function", "function": {
818+
"name": "get_weather",
819+
"description": description,
820+
"parameters": {"type": "object", "properties": {"city": {"type": "string"}}}
821+
}}]
822+
}),
823+
};
824+
825+
call(&provider, request_with_tool("Get the weather for a city.")).await;
826+
call(
827+
&provider,
828+
request_with_tool("Look up the current weather (reworded)."),
829+
)
830+
.await;
831+
832+
assert_eq!(
833+
calls.load(Ordering::SeqCst),
834+
1,
835+
"logical keying must serve the reworded-tool repeat from cache"
836+
);
837+
}
838+
795839
#[tokio::test]
796840
async fn errors_are_not_cached() {
797841
let _guard = TEST_MUTEX.lock().await;

0 commit comments

Comments
 (0)