Skip to content

Commit 3a8a964

Browse files
fix: preserve Responses cache salt (vllm-project#96)
## Summary - Preserve the optional Responses `cache_salt` when parsing `RequestPayload` and serializing the normalized request sent upstream. - Add a regression test proving that a client/internal salt survives `RequestPayload::to_upstream_request`. - Initialize the new optional field in existing test, benchmark, and internal request literals. ## Why The gateway previously accepted JSON containing `cache_salt` but silently discarded the unknown field during deserialization. In a KV-aware llm-d deployment, llm-d folds the salt into its first canonical request key and vLLM includes it in APC block identity. Dropping the field weakens tenant isolation and prevents llm-d's salted request keys from matching vLLM's salted KV events. This implementation is intentionally separate from the ADR and benchmark evidence in vllm-project#65. ## Test Plan - `cargo test -p agentic-server-core request_payload_forwards_cache_salt_upstream -- --nocapture` - `cargo test` - `cargo clippy --all-targets -- -D warnings` - `cargo fmt --all -- --check` - `uvx pre-commit run --all-files` Signed-off-by: Francisco Javier Arceo <farceo@redhat.com>
1 parent e108259 commit 3a8a964

7 files changed

Lines changed: 41 additions & 0 deletions

File tree

crates/agentic-server-core/benches/executor_throughput.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ fn make_request(input: &str, stream: bool, prev_id: Option<String>) -> RequestPa
145145
max_output_tokens: None,
146146
truncation: None,
147147
metadata: None,
148+
cache_salt: None,
148149
}
149150
}
150151

crates/agentic-server-core/src/executor/modes/conversation.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ mod tests {
141141
max_output_tokens: None,
142142
truncation: None,
143143
metadata: None,
144+
cache_salt: None,
144145
};
145146
RequestContext {
146147
enriched_request: req.clone(),

crates/agentic-server-core/src/executor/modes/response.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ mod tests {
120120
max_output_tokens: None,
121121
truncation: None,
122122
metadata: None,
123+
cache_salt: None,
123124
};
124125
RequestContext {
125126
enriched_request: req.clone(),

crates/agentic-server-core/src/types/request_response.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ pub struct RequestPayload {
2828
pub max_output_tokens: Option<u32>,
2929
pub truncation: Option<String>,
3030
pub metadata: Option<Value>,
31+
#[serde(default, skip_serializing_if = "Option::is_none")]
32+
pub cache_salt: Option<String>,
3133
}
3234

3335
fn default_true() -> bool {
@@ -61,6 +63,8 @@ pub struct UpstreamRequest<'a> {
6163
pub truncation: Option<&'a str>,
6264
#[serde(skip_serializing_if = "Option::is_none")]
6365
pub metadata: Option<&'a Value>,
66+
#[serde(skip_serializing_if = "Option::is_none")]
67+
pub cache_salt: Option<&'a str>,
6468
}
6569

6670
// serde's `skip_serializing_if` requires a `&Option<T>` receiver, so the
@@ -109,6 +113,7 @@ impl RequestPayload {
109113
max_output_tokens: self.max_output_tokens,
110114
truncation: self.truncation.as_deref(),
111115
metadata: self.metadata.as_ref(),
116+
cache_salt: self.cache_salt.as_deref(),
112117
})
113118
}
114119
}
@@ -178,6 +183,21 @@ impl From<&ResponsesInput> for Vec<InputItem> {
178183
mod tests {
179184
use super::*;
180185

186+
#[test]
187+
fn request_payload_forwards_cache_salt_upstream() {
188+
let payload: RequestPayload = serde_json::from_value(serde_json::json!({
189+
"model": "test-model",
190+
"input": "hello",
191+
"cache_salt": "tenant-a"
192+
}))
193+
.expect("request should deserialize");
194+
195+
let upstream = serde_json::to_value(payload.to_upstream_request(false).expect("request should normalize"))
196+
.expect("upstream request should serialize");
197+
198+
assert_eq!(upstream["cache_salt"], "tenant-a");
199+
}
200+
181201
#[test]
182202
fn request_payload_uses_option_tool_choice_for_missing_vs_explicit() {
183203
let absent: RequestPayload = serde_json::from_value(serde_json::json!({

crates/agentic-server-core/tests/dispatch_loop_cassette_test.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ fn request(text: &str, tools: Option<Vec<ResponsesTool>>) -> RequestPayload {
127127
max_output_tokens: Some(1024),
128128
truncation: None,
129129
metadata: None,
130+
cache_salt: None,
130131
}
131132
}
132133

crates/agentic-server-core/tests/support/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,7 @@ pub fn make_request(
369369
max_output_tokens: None,
370370
truncation: None,
371371
metadata: None,
372+
cache_salt: None,
372373
}
373374
}
374375

crates/agentic-server-core/tests/web_search_tool_test.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -584,6 +584,7 @@ async fn execute_runs_web_search_and_sends_tool_output_back_to_model() {
584584
max_output_tokens: Some(1024),
585585
truncation: None,
586586
metadata: None,
587+
cache_salt: None,
587588
};
588589

589590
let result = ExecuteRequest::new(payload, Arc::clone(&exec_ctx)).run().await.unwrap();
@@ -667,6 +668,7 @@ async fn execute_relaxes_forced_tool_choice_after_web_search_result() {
667668
max_output_tokens: Some(1024),
668669
truncation: None,
669670
metadata: None,
671+
cache_salt: None,
670672
};
671673

672674
let result = ExecuteRequest::new(payload, Arc::clone(&exec_ctx)).run().await.unwrap();
@@ -714,6 +716,7 @@ async fn execute_returns_mixed_client_tool_calls_without_followup_model_request(
714716
max_output_tokens: Some(1024),
715717
truncation: None,
716718
metadata: None,
719+
cache_salt: None,
717720
};
718721

719722
let result = ExecuteRequest::new(payload, Arc::clone(&exec_ctx)).run().await.unwrap();
@@ -760,6 +763,7 @@ async fn execute_returns_mixed_client_tool_calls_without_followup_model_request(
760763
max_output_tokens: Some(1024),
761764
truncation: None,
762765
metadata: None,
766+
cache_salt: None,
763767
};
764768
let continuation = ExecuteRequest::new(continuation_payload, exec_ctx).run().await.unwrap();
765769
assert!(matches!(continuation, Either::Left(_)));
@@ -829,6 +833,7 @@ async fn execute_accumulates_usage_across_web_search_model_rounds() {
829833
max_output_tokens: Some(1024),
830834
truncation: None,
831835
metadata: None,
836+
cache_salt: None,
832837
};
833838

834839
let result = ExecuteRequest::new(payload, exec_ctx).run().await.unwrap();
@@ -871,6 +876,7 @@ async fn stream_emits_web_search_lifecycle_events_before_final_payload() {
871876
max_output_tokens: Some(1024),
872877
truncation: None,
873878
metadata: None,
879+
cache_salt: None,
874880
};
875881

876882
let result = ExecuteRequest::new(payload, Arc::clone(&exec_ctx)).run().await.unwrap();
@@ -952,6 +958,7 @@ async fn stream_hides_web_search_function_events_when_name_arrives_on_done() {
952958
max_output_tokens: Some(1024),
953959
truncation: None,
954960
metadata: None,
961+
cache_salt: None,
955962
};
956963

957964
let result = ExecuteRequest::new(payload, Arc::clone(&exec_ctx)).run().await.unwrap();
@@ -1017,6 +1024,7 @@ async fn execute_runs_multiple_web_search_calls_concurrently() {
10171024
max_output_tokens: Some(1024),
10181025
truncation: None,
10191026
metadata: None,
1027+
cache_salt: None,
10201028
};
10211029

10221030
let result = tokio::time::timeout(Duration::from_secs(2), ExecuteRequest::new(payload, exec_ctx).run())
@@ -1064,6 +1072,7 @@ async fn execute_feeds_web_search_execution_errors_back_to_model() {
10641072
max_output_tokens: Some(1024),
10651073
truncation: None,
10661074
metadata: None,
1075+
cache_salt: None,
10671076
};
10681077

10691078
let result = ExecuteRequest::new(payload, exec_ctx).run().await.unwrap();
@@ -1113,6 +1122,7 @@ async fn execute_returns_incomplete_after_max_gateway_tool_rounds() {
11131122
max_output_tokens: Some(1024),
11141123
truncation: None,
11151124
metadata: None,
1125+
cache_salt: None,
11161126
};
11171127

11181128
// Budget exhausted while the model keeps requesting tools → the response is
@@ -1162,6 +1172,7 @@ async fn execute_feeds_invalid_web_search_arguments_back_to_model() {
11621172
max_output_tokens: Some(1024),
11631173
truncation: None,
11641174
metadata: None,
1175+
cache_salt: None,
11651176
};
11661177

11671178
let result = ExecuteRequest::new(payload, exec_ctx).run().await.unwrap();
@@ -1218,6 +1229,7 @@ async fn execute_runs_large_gateway_fanout_without_hard_cap() {
12181229
max_output_tokens: Some(1024),
12191230
truncation: None,
12201231
metadata: None,
1232+
cache_salt: None,
12211233
};
12221234

12231235
let result = ExecuteRequest::new(payload, exec_ctx)
@@ -1280,6 +1292,7 @@ async fn stream_error_events_escape_error_messages() {
12801292
max_output_tokens: Some(1024),
12811293
truncation: None,
12821294
metadata: None,
1295+
cache_salt: None,
12831296
};
12841297

12851298
let result = ExecuteRequest::new(payload, exec_ctx).run().await.unwrap();
@@ -1354,6 +1367,7 @@ async fn incomplete_turn_persists_a_consistent_conversation_for_continuation() {
13541367
max_output_tokens: Some(1024),
13551368
truncation: None,
13561369
metadata: None,
1370+
cache_salt: None,
13571371
};
13581372

13591373
let result = ExecuteRequest::new(payload, Arc::clone(&exec_ctx)).run().await.unwrap();
@@ -1379,6 +1393,7 @@ async fn incomplete_turn_persists_a_consistent_conversation_for_continuation() {
13791393
max_output_tokens: Some(1024),
13801394
truncation: None,
13811395
metadata: None,
1396+
cache_salt: None,
13821397
};
13831398
let _ = ExecuteRequest::new(continuation_payload, exec_ctx).run().await.unwrap();
13841399

@@ -1448,6 +1463,7 @@ async fn stream_returns_incomplete_after_max_gateway_tool_rounds() {
14481463
max_output_tokens: Some(1024),
14491464
truncation: None,
14501465
metadata: None,
1466+
cache_salt: None,
14511467
};
14521468

14531469
let result = ExecuteRequest::new(payload, exec_ctx).run().await.unwrap();

0 commit comments

Comments
 (0)