feat(ai): session-level prompt cache key for openai responses

Closes #9232
This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-09-16 23:23:32 +08:00
1 parent b969a86d38
commit 57e98b6d86
7 files changed
+97

No files matched your search

+28
View File
@@ -85,6 +85,9 @@ pub struct AgentLoopContext {
/// Turn limit for this run, already clamped by the settings layer.
/// Callers that have no user setting should pass [`DEFAULT_MAX_AGENT_TURNS`].
pub max_agent_turns: u32,
/// Stable per-conversation key forwarded to providers that support prompt
/// caching (OpenAI Responses API). `None` disables the field entirely.
pub prompt_cache_key: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -283,6 +286,7 @@ pub async fn run_agent_loop(
&on_event,
cancelled,
false,
agent_ctx.prompt_cache_key.as_deref(),
)
.await,
CompactResult::Cancelled
@@ -306,6 +310,7 @@ pub async fn run_agent_loop(
&tools,
max_tokens,
task_contract.clone(),
agent_ctx.prompt_cache_key.clone(),
);
// Stream the LLM response, collecting text and tool_calls.
@@ -372,6 +377,7 @@ pub async fn run_agent_loop(
&on_event,
cancelled,
true,
agent_ctx.prompt_cache_key.as_deref(),
)
.await;
match compacted {
@@ -659,6 +665,7 @@ fn build_tool_request(
_tools: &[ToolDefinition], // Tools are injected in ai::stream_with_tools, not via AiCompletionRequest.
max_tokens: Option<u32>,
task_contract: Option<AiTaskContract>,
prompt_cache_key: Option<String>,
) -> AiCompletionRequest {
// Note: tools are passed via the body, not via AiCompletionRequest.
// The actual injection happens in stream_with_tools.
@@ -668,6 +675,7 @@ fn build_tool_request(
messages: messages.to_vec(),
task_contract,
max_tokens: max_tokens.or(Some(4096)),
prompt_cache_key,
}
}
@@ -920,6 +928,7 @@ async fn run_agent_loop_text_only(
messages: messages.to_vec(),
task_contract: task_contract.cloned(),
max_tokens: max_tokens.or(Some(4096)),
prompt_cache_key: agent_ctx.prompt_cache_key.clone(),
};
for attempt in 0..=MAX_CONTRACT_REPAIR_ATTEMPTS {
@@ -1127,6 +1136,7 @@ async fn maybe_compact(
on_event: &(impl Fn(AgentEvent) + Send + Sync),
cancelled: &Notify,
force: bool,
prompt_cache_key: Option<&str>,
) -> CompactResult {
let window = effective_context_window(config);
let budget = prompt_budget(window, max_tokens);
@@ -1183,6 +1193,7 @@ async fn maybe_compact(
}],
task_contract: None,
max_tokens: Some(1024),
prompt_cache_key: prompt_cache_key.map(str::to_string),
};
let summary = match cancelled.notified().now_or_never() {
@@ -1516,6 +1527,23 @@ mod tests {
assert_eq!(effective_context_window(&config), 65_536);
}
#[test]
fn build_tool_request_carries_prompt_cache_key() {
let config: AiConfig = serde_json::from_value(serde_json::json!({
"provider": "openai",
"model": "test-model",
}))
.unwrap();
// Every agent turn rebuilds the request here, so this is the join that
// used to drop the conversation key before it reached the provider.
let with_key = build_tool_request(&config, "system", &[], &[], Some(128), None, Some("conv-1".to_string()));
assert_eq!(with_key.prompt_cache_key.as_deref(), Some("conv-1"));
let without_key = build_tool_request(&config, "system", &[], &[], Some(128), None, None);
assert!(without_key.prompt_cache_key.is_none());
}
#[test]
fn identifies_explicit_tool_rejection_errors() {
for error in [
+59
View File
@@ -571,6 +571,19 @@ pub struct AiCompletionRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub task_contract: Option<AiTaskContract>,
pub max_tokens: Option<u32>,
/// Stable key for reusing the provider's prompt cache within one conversation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt_cache_key: Option<String>,
}
fn apply_prompt_cache_key(body: &mut serde_json::Value, request: &AiCompletionRequest) {
// Only OpenAI's Responses API documents this field. Other providers may
// expose a Responses-compatible route with a strict request schema.
if request.config.api_style == AiApiStyle::Responses && is_openai_api_config(&request.config) {
if let Some(key) = request.prompt_cache_key.as_deref().filter(|key| !key.is_empty()) {
body["prompt_cache_key"] = json!(key);
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -2402,6 +2415,7 @@ pub async fn call_responses_api(client: &reqwest::Client, request: AiCompletionR
"max_output_tokens": responses_max_output_tokens(request.max_tokens, &request.config),
});
crate::ai_effort::apply_runtime_effort(&mut body, &request.config);
apply_prompt_cache_key(&mut body, &request);
let res = client
.post(resolve_endpoint(&request.config))
@@ -3959,6 +3973,7 @@ async fn stream_responses_api(
"stream": true,
});
crate::ai_effort::apply_runtime_effort(&mut body, &request.config);
apply_prompt_cache_key(&mut body, request);
let endpoint = resolve_endpoint(&request.config);
let config = request.config.clone();
@@ -4661,6 +4676,7 @@ async fn stream_responses_with_tools(
"stream": true,
});
crate::ai_effort::apply_runtime_effort(&mut body, &request.config);
apply_prompt_cache_key(&mut body, request);
let endpoint = resolve_endpoint(&request.config);
let config = request.config.clone();
@@ -5462,6 +5478,7 @@ mod tests {
}],
task_contract: None,
max_tokens: Some(64),
prompt_cache_key: None,
}
}
@@ -5498,6 +5515,7 @@ mod tests {
}],
task_contract: None,
max_tokens: Some(64),
prompt_cache_key: None,
}
}
@@ -8386,6 +8404,7 @@ mod tests {
}],
task_contract: None,
max_tokens: Some(64),
prompt_cache_key: None,
};
let client = build_ai_http_client(&config, 10).unwrap();
let events = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
@@ -9217,6 +9236,46 @@ mod tests {
.unwrap()
}
fn prompt_cache_test_request(config: AiConfig, key: Option<&str>) -> AiCompletionRequest {
AiCompletionRequest {
config,
system_prompt: "Be concise.".to_string(),
messages: Vec::new(),
task_contract: None,
max_tokens: Some(64),
prompt_cache_key: key.map(str::to_string),
}
}
#[test]
fn prompt_cache_key_is_injected_only_for_openai_responses() {
let body = |config: AiConfig, key: Option<&str>| {
let mut body = serde_json::json!({ "model": "test-model" });
super::apply_prompt_cache_key(&mut body, &prompt_cache_test_request(config, key));
body
};
let key_of = |value: &serde_json::Value| value.get("prompt_cache_key").cloned();
let mut responses = test_config(AiProvider::Openai);
responses.api_style = AiApiStyle::Responses;
assert_eq!(key_of(&body(responses, Some("conv-42"))), Some(serde_json::Value::from("conv-42")));
// The field is documented only for OpenAI's Responses API. Chat
// completions, other providers, and empty keys must stay untouched.
let mut completions = test_config(AiProvider::Openai);
completions.api_style = AiApiStyle::Completions;
assert_eq!(key_of(&body(completions, Some("conv-42"))), None);
let mut compatible = test_config(AiProvider::OpenaiCompatible);
compatible.api_style = AiApiStyle::Responses;
assert_eq!(key_of(&body(compatible, Some("conv-42"))), None);
let mut responses = test_config(AiProvider::Openai);
responses.api_style = AiApiStyle::Responses;
assert_eq!(key_of(&body(responses.clone(), Some(""))), None);
assert_eq!(key_of(&body(responses, None)), None);
}
#[test]
fn merge_global_max_retries_sets_for_api_providers() {
for provider in &[
+1
View File
@@ -460,6 +460,7 @@ pub async fn ai_agent_stream(
cli_mcp_server_command: None,
sql_permissions,
max_agent_turns,
prompt_cache_key: request.prompt_cache_key.clone(),
};
let sid = session_id.clone();