Skip to content
Open
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
14 changes: 10 additions & 4 deletions crates/adaptive/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use nemo_relay::plugin::ConfigPolicy;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value as Json};

use crate::response_cache::config::{BackendConfig, KEY_STRATEGY_EXACT_REQUEST};
use crate::response_cache::config::{BackendConfig, KEY_STRATEGY_EXACT_REQUEST, ToolCacheConfig};

/// Canonical config document for the adaptive plugin component.
#[derive(Debug, Clone, Serialize, Deserialize)]
Expand All @@ -34,8 +34,8 @@ pub struct AdaptiveConfig {
/// Adaptive Cache Governor settings.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub acg: Option<AcgComponentConfig>,
/// Opt-in LLM response cache (exact-match). When present, the
/// adaptive plugin installs the response-cache execution intercept(s).
/// Opt-in exact-match LLM response and tool-result cache. When present,
/// the adaptive plugin installs the response-cache execution intercept(s).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub response_cache: Option<ResponseCacheConfig>,
/// Adaptive-local unsupported-config policy.
Expand Down Expand Up @@ -191,7 +191,8 @@ impl Default for AcgComponentConfig {
}
}

/// Configuration for the adaptive plugin's `response_cache` feature
/// Configuration for the adaptive plugin's exact-match LLM response and
/// opt-in tool-result cache feature.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ResponseCacheConfig {
Expand All @@ -217,6 +218,9 @@ pub struct ResponseCacheConfig {
pub header_allowlist: Vec<String>,
/// Storage backend selection.
pub backend: BackendConfig,
/// Opt-in tool-result cache configuration.
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<ToolCacheConfig>,
}

impl Default for ResponseCacheConfig {
Expand All @@ -230,6 +234,7 @@ impl Default for ResponseCacheConfig {
key_strategy: KEY_STRATEGY_EXACT_REQUEST.to_string(),
header_allowlist: Vec::new(),
backend: BackendConfig::default(),
tools: None,
}
}
}
Expand Down Expand Up @@ -405,6 +410,7 @@ nemo_relay::editor_config! {
nested: BackendConfig,
default: BackendConfig,
},
tools => { label: "tools", kind: Json, optional: true },
}
}

Expand Down
3 changes: 2 additions & 1 deletion crates/adaptive/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ pub mod learner;
pub mod plugin_component;
#[cfg(feature = "redis-backend")]
pub mod redis;
/// Opt-in LLM response cache (exact-match).
/// Opt-in exact-match LLM response and tool-result cache.
pub mod response_cache;
mod runtime;
/// Storage backends and backend traits for adaptive state persistence.
Expand All @@ -58,6 +58,7 @@ pub use error::{AdaptiveError, Result};
#[cfg(feature = "redis-backend")]
pub use redis::RedisBackend;
pub use response_cache::RESPONSE_CACHE_MARK;
pub use response_cache::config::{ToolCacheConfig, ToolClass, ToolOverride};
pub use runtime::features::AdaptiveRuntime;
pub use storage::erased::AnyBackend;
pub use storage::memory::InMemoryBackend;
Expand Down
76 changes: 76 additions & 0 deletions crates/adaptive/src/plugin_component.rs
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,7 @@ fn validate_response_cache_section(
"key_strategy",
"header_allowlist",
"backend",
"tools",
],
);
if let Some(backend_json) = response_cache_json.get("backend").and_then(Json::as_object) {
Expand All @@ -361,6 +362,9 @@ fn validate_response_cache_section(
);
}
}
if let Some(tools_json) = response_cache_json.get("tools").and_then(Json::as_object) {
validate_response_cache_tools_fields(diagnostics, policy, tools_json);
}
}

fn validate_response_cache_backend_config_fields(
Expand All @@ -383,6 +387,78 @@ fn validate_response_cache_backend_config_fields(
);
}

fn validate_response_cache_tools_fields(
diagnostics: &mut Vec<ConfigDiagnostic>,
policy: &ConfigPolicy,
tools_json: &Map<String, Json>,
) {
const CLASS_FIELDS: &[&str] = &[
"cacheable",
"ttl_seconds",
"bypass_rate",
"arg_skip",
"members",
];
const OVERRIDE_FIELDS: &[&str] = &[
"cacheable",
"ttl_seconds",
"bypass_rate",
"tool_version",
"arg_skip",
];

validate_unknown_fields(
diagnostics,
policy,
Some("response_cache.tools".to_string()),
tools_json,
&[
"enabled",
"priority",
"cache_errors",
"default",
"classes",
"overrides",
],
);

if let Some(default_json) = tools_json.get("default").and_then(Json::as_object) {
validate_unknown_fields(
diagnostics,
policy,
Some("response_cache.tools.default".to_string()),
default_json,
CLASS_FIELDS,
);
}
if let Some(classes_json) = tools_json.get("classes").and_then(Json::as_object) {
for (class_name, class_value) in classes_json {
if let Some(class_object) = class_value.as_object() {
validate_unknown_fields(
diagnostics,
policy,
Some(format!("response_cache.tools.classes.{class_name}")),
class_object,
CLASS_FIELDS,
);
}
}
}
if let Some(overrides_json) = tools_json.get("overrides").and_then(Json::as_object) {
for (tool_name, override_value) in overrides_json {
if let Some(override_object) = override_value.as_object() {
validate_unknown_fields(
diagnostics,
policy,
Some(format!("response_cache.tools.overrides.{tool_name}")),
override_object,
OVERRIDE_FIELDS,
);
}
}
}
}

fn validate_backend_config_fields(
diagnostics: &mut Vec<ConfigDiagnostic>,
policy: &ConfigPolicy,
Expand Down
75 changes: 75 additions & 0 deletions crates/adaptive/src/response_cache/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
//! response-cache-specific backend config and the key-strategy constant next to
//! the key/store code that consumes them.

use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};
use serde_json::{Map, Value as Json};

Expand Down Expand Up @@ -63,3 +65,76 @@ nemo_relay::editor_config! {
config => { label: "config", kind: Json },
}
}

/// Opt-in tool-result cache configuration.
///
/// Cache only tools that are read-only and stable for their TTL.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ToolCacheConfig {
/// Master switch; off by default.
pub enabled: bool,
/// Tool execution-intercept priority. The default keeps standard
/// priority-100 guardrails outside cache hits.
pub priority: i32,
/// Whether conventional in-band tool error results may be stored.
pub cache_errors: bool,
/// Policy for unclassified tools; not cacheable by default.
pub default: ToolClass,
/// Named tool classes.
pub classes: BTreeMap<String, ToolClass>,
/// Per-tool refinements keyed by exact name or wildcard.
pub overrides: BTreeMap<String, ToolOverride>,
}

impl Default for ToolCacheConfig {
fn default() -> Self {
Self {
enabled: false,
priority: 150,
cache_errors: false,
default: ToolClass::default(),
classes: BTreeMap::new(),
overrides: BTreeMap::new(),
}
}
}

/// Policy shared by a class of tools.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct ToolClass {
/// Whether class members may be served from cache.
pub cacheable: bool,
/// TTL in seconds; inherits the response-cache TTL when unset.
#[serde(skip_serializing_if = "Option::is_none")]
pub ttl_seconds: Option<u64>,
/// Live-rerun probability; inherits the response-cache rate when unset.
#[serde(skip_serializing_if = "Option::is_none")]
pub bypass_rate: Option<f64>,
/// Top-level argument keys dropped before keying.
pub arg_skip: Vec<String>,
/// Exact tool names or `*` wildcard patterns in this class.
pub members: Vec<String>,
}

/// Per-tool refinement applied after class resolution.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct ToolOverride {
/// Overrides the class cacheability.
#[serde(skip_serializing_if = "Option::is_none")]
pub cacheable: Option<bool>,
/// Overrides the class TTL.
#[serde(skip_serializing_if = "Option::is_none")]
pub ttl_seconds: Option<u64>,
/// Overrides the class bypass rate.
#[serde(skip_serializing_if = "Option::is_none")]
pub bypass_rate: Option<f64>,
/// Version string folded into the cache key.
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_version: Option<String>,
/// Replaces the class argument skip list when set.
#[serde(skip_serializing_if = "Option::is_none")]
pub arg_skip: Option<Vec<String>>,
}
67 changes: 66 additions & 1 deletion crates/adaptive/src/response_cache/key.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
//! skip-list drops volatile/identity fields, tool-call IDs are normalized, and
//! only allowlisted headers plus Relay-owned routing partitions fold in.

use std::collections::BTreeSet;

use nemo_relay::api::llm::LlmRequest;
use nemo_relay::codec::request::AnnotatedLlmRequest;
use nemo_relay::codec::resolve::{
Expand Down Expand Up @@ -74,7 +76,8 @@ pub fn build_cache_key(
normalize_tool_call_ids(object);
}

let headers = cache_key_headers(&request.headers, &config.header_allowlist);
let header_allowlist = normalized_header_allowlist(&config.header_allowlist);
let headers = cache_key_headers(&request.headers, &header_allowlist);

let key_doc = json!({
"v": CACHE_SCHEMA_VERSION,
Expand All @@ -85,6 +88,7 @@ pub fn build_cache_key(
"openai_chat_token_cap": chat_token_cap_spelling,
"body": body,
"headers": headers,
"header_allowlist": header_allowlist,
});
if contains_unrepresentable_int(&key_doc) {
return KeyOutcome::Bypass("unrepresentable_number");
Expand Down Expand Up @@ -211,6 +215,47 @@ impl std::io::Write for HashWriter<'_> {
}
}

/// Builds a tool-result key from its name, version, canonicalized arguments,
/// and the effective cache policies.
pub fn build_tool_cache_key(
namespace: &str,
tool_name: &str,
tool_version: Option<&str>,
args: &Json,
arg_skip: &[String],
cache_errors: bool,
) -> KeyOutcome {
let arg_skip = normalized_arg_skip(arg_skip);
let mut args = args.clone();
if !arg_skip.is_empty()
&& let Some(object) = args.as_object_mut()
{
for key in &arg_skip {
object.remove(key);
}
}

if contains_unrepresentable_int(&args) {
return KeyOutcome::Bypass("unrepresentable_number");
}

let key_doc = json!({
"v": CACHE_SCHEMA_VERSION,
"surface": "tool_result",
"ns": namespace,
"tool": tool_name,
"tool_version": tool_version,
"arg_skip": arg_skip,
"cache_errors": cache_errors,
"args": args,
});

match fingerprint(&key_doc) {
Some(key) => KeyOutcome::Key(key),
None => KeyOutcome::Bypass("canonicalization_failed"),
}
}

/// The body to fingerprint plus the codec that actually produced it.
///
/// The surface is auto-detected from the request shape, hinted by the provider
Expand Down Expand Up @@ -400,6 +445,26 @@ fn allowlisted_headers(headers: &Map<String, Json>, allowlist: &[String]) -> Map
kept
}

/// Normalizes case-insensitive header policy names before keying them.
fn normalized_header_allowlist(allowlist: &[String]) -> Vec<String> {
allowlist
.iter()
.map(|name| name.to_ascii_lowercase())
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}

/// Normalizes the case-sensitive tool argument keys dropped before keying.
fn normalized_arg_skip(arg_skip: &[String]) -> Vec<String> {
arg_skip
.iter()
.cloned()
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}

/// Builds the key's header partition from configured headers plus the
/// Relay-owned Switchyard backend ID.
fn cache_key_headers(headers: &Map<String, Json>, allowlist: &[String]) -> Map<String, Json> {
Expand Down
Loading
Loading