diff --git a/src/agent.rs b/src/agent.rs index fdd0f79..af8d3c4 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -80,6 +80,9 @@ pub struct RunResult { pub messages: Vec, pub input_tokens: u32, pub output_tokens: u32, + /// Portion of `input_tokens` the provider served from its prompt cache + /// across this turn's steps (0 when the provider reports no cache details). + pub cached_input_tokens: u32, pub reported_cost_usd: Option, pub transcript_rewritten: bool, pub conversation_summary: Option, @@ -311,7 +314,11 @@ where let mut total_in: u32 = 0; let mut total_out: u32 = 0; + let mut total_cached: u32 = 0; let mut reported_cost_usd: Option = None; + // Prefix identity is fixed for the turn (system message + model are stable + // across steps), so derive the cache-routing key once and reuse it. + let cache_key = crate::openai::session_cache_key(backend, model, &messages); let mut transcript_rewritten = false; let mut natural_stop = false; let mut conversation_summary = guard @@ -351,6 +358,7 @@ where include_usage: true, }), max_tokens: None, + prompt_cache_key: cache_key.as_deref(), effort, }; @@ -418,6 +426,7 @@ where if let Some(usage) = &chunk.usage { total_in += usage.prompt_tokens; total_out += usage.completion_tokens; + total_cached += usage.cached_tokens(); if let Some(cost) = usage.cost { step_reported_cost_usd = Some(cost); } @@ -1012,6 +1021,7 @@ where messages, input_tokens: total_in, output_tokens: total_out, + cached_input_tokens: total_cached, reported_cost_usd, transcript_rewritten, conversation_summary, diff --git a/src/agent_eval.rs b/src/agent_eval.rs index 8adea56..e161ab3 100644 --- a/src/agent_eval.rs +++ b/src/agent_eval.rs @@ -648,6 +648,7 @@ mod tests { messages: vec![], input_tokens: 0, output_tokens: 0, + cached_input_tokens: 0, reported_cost_usd: None, transcript_rewritten: false, conversation_summary: None, diff --git a/src/auto_loop.rs b/src/auto_loop.rs index 8a4e1a3..f2b198f 100644 --- a/src/auto_loop.rs +++ b/src/auto_loop.rs @@ -493,6 +493,7 @@ pub(crate) async fn run_done_check( include_usage: false, }), max_tokens: Some(400), + prompt_cache_key: None, effort: None, }; let mut raw = String::new(); diff --git a/src/codex_responses.rs b/src/codex_responses.rs index 03fea46..901df65 100644 --- a/src/codex_responses.rs +++ b/src/codex_responses.rs @@ -213,12 +213,17 @@ fn one_tool_chunk( } } -fn usage_chunk(input: u32, output: u32) -> StreamChunk { +fn usage_chunk(input: u32, output: u32, cached: u32) -> StreamChunk { StreamChunk { choices: Vec::new(), usage: Some(Usage { prompt_tokens: input, completion_tokens: output, + // The Responses API reports cache reuse under + // `input_tokens_details.cached_tokens`; surface it like the chat path. + prompt_tokens_details: (cached > 0).then_some(crate::openai::PromptTokensDetails { + cached_tokens: cached, + }), cost: None, }), } @@ -372,8 +377,13 @@ where .get("output_tokens") .and_then(Value::as_u64) .unwrap_or(0) as u32; + let cached = usage + .get("input_tokens_details") + .and_then(|d| d.get("cached_tokens")) + .and_then(Value::as_u64) + .unwrap_or(0) as u32; if input > 0 || output > 0 { - on_chunk(usage_chunk(input, output)); + on_chunk(usage_chunk(input, output, cached)); } } return Ok(true); @@ -531,6 +541,7 @@ mod tests { stream: true, stream_options: None, max_tokens: None, + prompt_cache_key: None, effort: None, }; let body = build_request_body(&req); @@ -566,4 +577,50 @@ mod tests { assert_eq!(call.id.as_deref(), Some("call_1")); assert_eq!(call.function.unwrap().name.as_deref(), Some("grep")); } + + #[test] + fn response_completed_surfaces_cached_prompt_tokens() { + let mut chunks = Vec::new(); + let mut calls = BTreeMap::new(); + let mut next_index = 0; + let done = handle_event( + json!({ + "type": "response.completed", + "response": {"usage": { + "input_tokens": 1200, + "output_tokens": 87, + "input_tokens_details": {"cached_tokens": 900} + }} + }), + &mut calls, + &mut next_index, + |c| chunks.push(c), + ) + .unwrap(); + assert!(done); + let usage = chunks[0].usage.as_ref().unwrap(); + assert_eq!(usage.prompt_tokens, 1200); + assert_eq!(usage.completion_tokens, 87); + assert_eq!(usage.cached_tokens(), 900); + } + + #[test] + fn response_completed_without_cache_details_reports_zero_cached() { + let mut chunks = Vec::new(); + let mut calls = BTreeMap::new(); + let mut next_index = 0; + handle_event( + json!({ + "type": "response.completed", + "response": {"usage": {"input_tokens": 40, "output_tokens": 10}} + }), + &mut calls, + &mut next_index, + |c| chunks.push(c), + ) + .unwrap(); + let usage = chunks[0].usage.as_ref().unwrap(); + assert_eq!(usage.cached_tokens(), 0); + assert!(usage.prompt_tokens_details.is_none()); + } } diff --git a/src/commands/config_cmds.rs b/src/commands/config_cmds.rs index b5b89a5..b1b7786 100644 --- a/src/commands/config_cmds.rs +++ b/src/commands/config_cmds.rs @@ -747,6 +747,7 @@ pub(super) async fn cmd_compare(args: &str, state: &AppState) -> Result<()> { include_usage: false, }), max_tokens: None, + prompt_cache_key: None, effort: None, }; let mut out = std::io::stdout(); diff --git a/src/commands/context_cmds.rs b/src/commands/context_cmds.rs index c4f9385..29c7d33 100644 --- a/src/commands/context_cmds.rs +++ b/src/commands/context_cmds.rs @@ -89,6 +89,7 @@ pub(crate) async fn perform_reset(state: &mut AppState, dry_run: bool) -> Result include_usage: false, }), max_tokens: Some(1200), + prompt_cache_key: None, effort: None, }; let mut draft = String::new(); diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs index 0266a01..570d0bc 100644 --- a/src/commands/doctor.rs +++ b/src/commands/doctor.rs @@ -169,6 +169,7 @@ async fn probe_streaming( include_usage: true, }), max_tokens: Some(8), + prompt_cache_key: None, effort: None, }; let mut chunks = 0usize; @@ -254,6 +255,7 @@ async fn probe_tool_calls( include_usage: false, }), max_tokens: Some(128), + prompt_cache_key: None, effort: None, }; let mut content = String::new(); @@ -575,6 +577,7 @@ async fn cmd_bench(args: &str, state: &AppState) -> Result<()> { include_usage: false, }), max_tokens: None, + prompt_cache_key: None, effort: state.active_effort, }; let start = Instant::now(); diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 2607a2f..ff44c43 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -534,6 +534,7 @@ async fn cmd_handoff(args: &str, state: &AppState) -> Result<()> { include_usage: false, }), max_tokens: Some(900), + prompt_cache_key: None, effort: None, }; let mut draft = String::new(); @@ -821,6 +822,7 @@ async fn cmd_plan(args: &str, state: &mut AppState) -> Result<()> { include_usage: false, }), max_tokens: Some(1500), + prompt_cache_key: None, effort: state.active_effort, }; let mut draft = String::new(); @@ -903,6 +905,7 @@ async fn cmd_plan_route( include_usage: true, }), max_tokens: Some(2400), + prompt_cache_key: None, effort: planner.effort, }; let mut draft = String::new(); diff --git a/src/commands/route.rs b/src/commands/route.rs index 44b42d5..b363bd3 100644 --- a/src/commands/route.rs +++ b/src/commands/route.rs @@ -417,6 +417,7 @@ async fn run_selector(state: &AppState, selector: &ModelRef, task: &str) -> Resu include_usage: true, }), max_tokens: Some(500), + prompt_cache_key: None, effort: selector.effort, }; let mut text = String::new(); diff --git a/src/commands/ship.rs b/src/commands/ship.rs index 6cf90fa..6c83040 100644 --- a/src/commands/ship.rs +++ b/src/commands/ship.rs @@ -1195,6 +1195,7 @@ async fn draft_ship_commit_message( include_usage: false, }), max_tokens: Some(500), + prompt_cache_key: None, effort: None, }; let mut draft = String::new(); diff --git a/src/commands/workflow.rs b/src/commands/workflow.rs index 7b5d91f..0543795 100644 --- a/src/commands/workflow.rs +++ b/src/commands/workflow.rs @@ -43,6 +43,7 @@ async fn eval_once( include_usage: false, }), max_tokens: None, + prompt_cache_key: None, effort: None, }; let mut out = String::new(); @@ -832,6 +833,7 @@ async fn run_prompt_content(content: &str, state: &mut AppState) -> Result<()> { include_usage: true, }), max_tokens: None, + prompt_cache_key: None, effort: state.active_effort, }; diff --git a/src/context_guard.rs b/src/context_guard.rs index b79e8f2..ca6e48d 100644 --- a/src/context_guard.rs +++ b/src/context_guard.rs @@ -533,6 +533,7 @@ pub async fn summarize_transcript( include_usage: false, }), max_tokens: None, + prompt_cache_key: None, effort: None, }; let mut out = String::new(); diff --git a/src/openai.rs b/src/openai.rs index 8deff62..16a2cc9 100644 --- a/src/openai.rs +++ b/src/openai.rs @@ -134,10 +134,38 @@ pub struct ChatRequest<'a> { pub stream_options: Option, #[serde(skip_serializing_if = "Option::is_none")] pub max_tokens: Option, + /// Stable per-session cache-routing hint for hosted providers (OpenAI + /// `prompt_cache_key`). Keeps a session's requests on the same cache so the + /// shared system+tools+history prefix stays warm across turns. Omitted for + /// local backends, which ignore it. See [`session_cache_key`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt_cache_key: Option<&'a str>, #[serde(skip)] pub effort: Option, } +/// Derive a stable prompt-cache-routing key from the request's cacheable prefix +/// (model + the position-0 system message, which the interactive loop keeps +/// byte-identical across turns). Same session/config yields the same key every +/// turn, so hosted providers co-locate the session and keep its prefix cached. +/// Returns `None` for local backends, which do not use the hint. +pub fn session_cache_key( + backend: &BackendDescriptor, + model: &str, + messages: &[ChatMessage], +) -> Option { + if backend.is_local { + return None; + } + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + model.hash(&mut hasher); + if let Some(ChatMessage::System { content }) = messages.first() { + content.hash(&mut hasher); + } + Some(format!("sh-{:016x}", hasher.finish())) +} + #[derive(Debug, Serialize)] pub struct StreamOptions { pub include_usage: bool, @@ -193,6 +221,28 @@ pub struct Usage { pub completion_tokens: u32, #[serde(default)] pub cost: Option, + /// Cached-prompt accounting, when the provider reports it. OpenAI and + /// OpenRouter nest the cache hit here as `prompt_tokens_details.cached_tokens` + /// (a subset of `prompt_tokens`); local backends omit it. + #[serde(default)] + pub prompt_tokens_details: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct PromptTokensDetails { + #[serde(default)] + pub cached_tokens: u32, +} + +impl Usage { + /// Prompt tokens served from the provider's prompt cache this call, or 0 + /// when the provider reports no cache details. + pub fn cached_tokens(&self) -> u32 { + self.prompt_tokens_details + .as_ref() + .map(|d| d.cached_tokens) + .unwrap_or(0) + } } pub fn build_http_client() -> reqwest::Client { @@ -714,6 +764,7 @@ mod tests { stream: true, stream_options: None, max_tokens: None, + prompt_cache_key: None, effort: None, }; @@ -725,6 +776,92 @@ mod tests { assert_eq!(plugin["max_tool_calls"], 4); } + fn hosted_backend() -> BackendDescriptor { + BackendDescriptor { + name: BackendName::OpenAi, + base_url: "https://api.openai.com/v1".into(), + api_key: "test".into(), + is_local: false, + openrouter: OpenRouterConfig::default(), + } + } + + #[test] + fn session_cache_key_is_stable_and_prefix_scoped() { + let hosted = hosted_backend(); + let sys = |t: &str| vec![ChatMessage::System { content: t.into() }]; + let a = sys("stable system prompt"); + + // Same model + same system prefix => identical key across turns. This is + // the whole point: a session keeps routing to the same warm cache shard. + assert_eq!( + session_cache_key(&hosted, "gpt-4o", &a), + session_cache_key(&hosted, "gpt-4o", &a), + ); + // A different model must not share a cache key. + assert_ne!( + session_cache_key(&hosted, "gpt-4o", &a), + session_cache_key(&hosted, "gpt-4o-mini", &a), + ); + // A different stable prefix (system prompt) must not share a cache key. + assert_ne!( + session_cache_key(&hosted, "gpt-4o", &a), + session_cache_key(&hosted, "gpt-4o", &sys("a different system prompt")), + ); + // Appending volatile turns below the prefix must NOT change the key, + // otherwise every turn would route to a cold shard. + let mut a_plus = a.clone(); + a_plus.push(ChatMessage::User { + content: "turn two".into(), + }); + assert_eq!( + session_cache_key(&hosted, "gpt-4o", &a), + session_cache_key(&hosted, "gpt-4o", &a_plus), + ); + + // Local backends opt out: no hint is emitted at all. + let local = BackendDescriptor { + name: BackendName::Ollama, + base_url: "http://localhost:11434/v1".into(), + api_key: String::new(), + is_local: true, + openrouter: OpenRouterConfig::default(), + }; + assert_eq!(session_cache_key(&local, "gpt-4o", &a), None); + } + + #[test] + fn prompt_cache_key_is_serialized_only_when_set() { + let backend = hosted_backend(); + let messages = vec![ChatMessage::User { + content: "hi".into(), + }]; + let base = ChatRequest { + model: "gpt-4o", + messages: &messages, + tools: None, + stream: true, + stream_options: None, + max_tokens: None, + prompt_cache_key: None, + effort: None, + }; + // Omitted when None (serde skip), so backends that don't support it never + // see the field. + let without = request_body(&backend, &base).unwrap(); + assert!(without.get("prompt_cache_key").is_none()); + // Round-trips onto the wire verbatim when present. + let with = request_body( + &backend, + &ChatRequest { + prompt_cache_key: Some("sh-deadbeef"), + ..base + }, + ) + .unwrap(); + assert_eq!(with["prompt_cache_key"], "sh-deadbeef"); + } + #[test] fn openrouter_effort_is_injected_as_reasoning() { let backend = BackendDescriptor { @@ -744,6 +881,7 @@ mod tests { stream: true, stream_options: None, max_tokens: None, + prompt_cache_key: None, effort: Some(EffortLevel::Max), }; @@ -771,6 +909,7 @@ mod tests { stream: true, stream_options: None, max_tokens: None, + prompt_cache_key: None, effort: Some(EffortLevel::High), }; @@ -804,6 +943,7 @@ mod tests { stream: true, stream_options: None, max_tokens: None, + prompt_cache_key: None, effort: None, }; @@ -875,6 +1015,7 @@ mod tests { stream: true, stream_options: None, max_tokens: None, + prompt_cache_key: None, effort: None, }; let mut name = String::new(); diff --git a/src/project_memory.rs b/src/project_memory.rs index 5677310..67b6171 100644 --- a/src/project_memory.rs +++ b/src/project_memory.rs @@ -658,22 +658,47 @@ pub fn maybe_project_context( Some(map.content) } +/// Header the prompt-focused repo map is injected under, whether it lives in +/// the system prompt (one-shot/eval callers) or is folded below the cache +/// boundary into the current user turn (the interactive loop). +pub const PROJECT_CONTEXT_HEADER: &str = "Local project memory context:"; + +fn append_project_prompt(out: &mut String, workspace_root: &str) { + if let Some(project_prompt) = load_project_prompt(workspace_root) { + out.push_str("\n\nProject-specific guidance (.small-harness/prompt.md):\n"); + out.push_str(&project_prompt); + } +} + +/// System prompt that stays byte-identical across turns within a session: +/// base prompt plus the static `.small-harness/prompt.md` guidance. +/// +/// It deliberately omits the prompt-focused repo map ([`maybe_project_context`]), +/// which is re-ranked against every user message and so changes each turn. +/// Keeping that volatile block out of the position-0 system message lets the +/// cached prefix (system + tools + prior turns) survive across turns for both +/// hosted prompt caching and local KV/prefix reuse. The interactive loop folds +/// the map into the current user turn instead, below the cache boundary. +pub fn render_stable_system_prompt(config: &AgentConfig, tools: &[String]) -> String { + let mut out = config.render_system_prompt_for_tools(tools); + append_project_prompt(&mut out, &config.workspace_root); + out +} + pub fn render_system_prompt_with_memory( config: &AgentConfig, backend: &BackendDescriptor, tools: &[String], prompt: &str, ) -> String { - let base = config.render_system_prompt_for_tools(tools); - let mut out = base; + let mut out = config.render_system_prompt_for_tools(tools); if let Some(context) = maybe_project_context(config, backend, prompt) { - out.push_str("\n\nLocal project memory context:\n"); + out.push_str("\n\n"); + out.push_str(PROJECT_CONTEXT_HEADER); + out.push('\n'); out.push_str(&context); } - if let Some(project_prompt) = load_project_prompt(&config.workspace_root) { - out.push_str("\n\nProject-specific guidance (.small-harness/prompt.md):\n"); - out.push_str(&project_prompt); - } + append_project_prompt(&mut out, &config.workspace_root); out } @@ -1418,6 +1443,49 @@ mod tests { )); } + #[test] + fn stable_system_prompt_excludes_prompt_focused_map() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("parser.rs"), "pub fn parse_tokens() {}\n").unwrap(); + fs::write(dir.path().join("render.rs"), "pub fn render_frame() {}\n").unwrap(); + let config = config_for(dir.path()); + build_project_index(&config).unwrap(); + let backend = local_backend(); + let tools = vec!["file_read".to_string()]; + + // The focused map exists and is a function of the current user prompt. + let ctx = + maybe_project_context(&config, &backend, "how does parse_tokens read code").unwrap(); + assert!(ctx.contains("Focused map for")); + + // One-shot callers still embed the map, so their prompt varies per turn + // — exactly what makes it unfit for the cache prefix. + let full_a = render_system_prompt_with_memory( + &config, + &backend, + &tools, + "how does parse_tokens read code", + ); + let full_b = render_system_prompt_with_memory( + &config, + &backend, + &tools, + "explain render_frame in this code", + ); + assert!(full_a.contains(PROJECT_CONTEXT_HEADER)); + assert_ne!( + full_a, full_b, + "system prompt with the map embedded changes with the user prompt" + ); + + // The stable prefix carries neither the header nor any focused map, and + // is identical regardless of the turn's prompt. + let stable = render_stable_system_prompt(&config, &tools); + assert!(!stable.contains(PROJECT_CONTEXT_HEADER)); + assert!(!stable.contains("Focused map for")); + assert_eq!(stable, render_stable_system_prompt(&config, &tools)); + } + #[test] fn notes_append_and_forget() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/session_turn.rs b/src/session_turn.rs index d53f1c6..1dec441 100644 --- a/src/session_turn.rs +++ b/src/session_turn.rs @@ -22,7 +22,10 @@ use crate::hooks::{ use crate::loader::Loader; use crate::model_system::EffortLevel; use crate::openai::{ChatMessage, ImageUrl, UserContent, UserContentPart}; -use crate::project_memory::{refresh_project_memory_after_write, render_system_prompt_with_memory}; +use crate::project_memory::{ + maybe_project_context, refresh_project_memory_after_write, render_stable_system_prompt, + PROJECT_CONTEXT_HEADER, +}; use crate::session::save_message; use crate::shipcheck::{append_ship_context, collect_shipcheck}; use crate::test_integration::{ @@ -177,6 +180,7 @@ fn format_effort_suffix(effort: Option) -> String { fn format_footer( input_tokens: u32, output_tokens: u32, + cached_input_tokens: u32, turn_cost: Option, backend_is_local: bool, session_usd: f64, @@ -191,6 +195,11 @@ fn format_footer( format!("{} in", format_tokens(input_tokens)), format!("{} out", format_tokens(output_tokens)), ]; + // Only surface cache reuse when the provider reported it, so local backends + // (which never report cached tokens) don't get a misleading "0 cached". + if cached_input_tokens > 0 { + parts.push(format!("{} cached", format_tokens(cached_input_tokens))); + } let cost = format_cost_suffix( turn_cost, backend_is_local, @@ -365,6 +374,27 @@ pub(crate) fn system_prompt_with_hook_context( system_prompt } +/// Fold the prompt-focused project context into the final user message of a +/// request so it rides below the stable system/tools/history cache prefix +/// instead of mutating the position-0 system message every turn. Mutates only +/// the request copy; durable history keeps the user's raw prompt. +fn fold_project_context_into_last_user(messages: &mut [ChatMessage], context: &str) { + for msg in messages.iter_mut().rev() { + if let ChatMessage::User { content } = msg { + let block = format!("{PROJECT_CONTEXT_HEADER}\n{context}\n\n"); + match content { + UserContent::Text(text) => { + *content = UserContent::Text(format!("{block}{text}")); + } + UserContent::Parts(parts) => { + parts.insert(0, UserContentPart::Text { text: block }); + } + } + return; + } + } +} + fn maybe_print_context_pressure( state: &AppState, system_prompt: &str, @@ -424,16 +454,16 @@ pub async fn run_user_turn(state: &mut AppState, opts: TurnOptions) -> Result Result Result assert_eq!(content, "STABLE SYSTEM"), + other => panic!("expected system at position 0, got {other:?}"), + } + assert_eq!(messages[1].user_text().unwrap(), "first question"); + + // The volatile map rides below the boundary, on the current user turn. + let last = messages[3].user_text().unwrap(); + assert_eq!( + last, + "Local project memory context:\nFocused map for `parser`:\n- src/x.rs\n\nhow does the parser work" + ); + } + + #[test] + fn project_context_folds_ahead_of_image_parts() { + let mut messages = vec![ChatMessage::User { + content: UserContent::Parts(vec![ + UserContentPart::Text { + text: "describe this".into(), + }, + UserContentPart::ImageUrl { + image_url: ImageUrl { + url: "data:image/png;base64,AAAA".into(), + }, + }, + ]), + }]; + fold_project_context_into_last_user(&mut messages, "MAP BODY"); + + let ChatMessage::User { + content: UserContent::Parts(parts), + } = &messages[0] + else { + panic!("expected multi-part user message"); + }; + assert_eq!(parts.len(), 3); + match &parts[0] { + UserContentPart::Text { text } => { + assert_eq!(text, "Local project memory context:\nMAP BODY\n\n"); + } + other => panic!("expected leading context text part, got {other:?}"), + } + assert!(matches!(parts[2], UserContentPart::ImageUrl { .. })); + } + + #[test] + fn folding_project_context_without_user_message_is_a_noop() { + let mut messages = vec![ChatMessage::System { + content: "sys".into(), + }]; + fold_project_context_into_last_user(&mut messages, "map"); + match &messages[0] { + ChatMessage::System { content } => assert_eq!(content, "sys"), + other => panic!("unexpected mutation: {other:?}"), + } + } + #[test] fn stop_hook_context_can_be_queued_for_next_prompt() { let mut outcome = HookOutcome::default(); diff --git a/src/setup.rs b/src/setup.rs index d5fc8ec..c088578 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -387,6 +387,7 @@ async fn probe_setup_backend(config: &AgentConfig) { stream: false, stream_options: None, max_tokens: Some(4), + prompt_cache_key: None, effort: None, }; match with_probe_timeout(chat_oneshot(&http, &backend_desc, &req)).await { diff --git a/src/warmup.rs b/src/warmup.rs index 68eb9d7..dd479fc 100644 --- a/src/warmup.rs +++ b/src/warmup.rs @@ -29,6 +29,7 @@ pub async fn warmup( stream: false, stream_options: None, max_tokens: Some(1), + prompt_cache_key: None, effort, }; chat_oneshot(http, backend, &req).await?;