From 543dedea8e652f92c5b7810d7aee1edd668b24ea Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:56:09 +0800 Subject: [PATCH 1/2] Restore a placeholder a stream-mode reply plugin splits in two In stream mode (`reply: "stream"`) the gateway restored placeholders in each string a plugin handed back on its own. A plugin that holds back the end of its text can cut a `<>` placeholder in two; neither half matches the ledger, so the client received the placeholder text instead of the original value. Whole (non-streaming) bodies were affected the same way, since a stream-mode plugin gets the block in `onReplyText` and then `onReplyTextEnd`, and the next plugin in the chain saw the two unrestored halves. Each plugin now keeps, per text block, a trailing piece of its output that is the unfinished start of a placeholder in the ledger, and restores it together with the next piece it hands back. At the end of the block the held piece is joined with the `onReplyTextEnd` output; an incomplete leftover goes out as is. Only placeholders in the ledger are held, so a `<<` in code waits at most for the plugin's next piece. When a plugin is skipped after an error, the held piece is given back with the rest. Also fixed on the same path: a stream-mode plugin skipped after failing in `onReplyTextEnd` lost the text it was holding, because the block's state was removed before the call. The state is now kept until the block ends. Co-Authored-By: Claude Opus 5.5 --- crates/tw-gateway/src/plugin/bridge.rs | 62 ++++++ crates/tw-gateway/src/plugin/reply/mod.rs | 73 +++++-- .../tw-gateway/src/plugin/reply/tests/mod.rs | 203 ++++++++++++++++++ 3 files changed, 318 insertions(+), 20 deletions(-) diff --git a/crates/tw-gateway/src/plugin/bridge.rs b/crates/tw-gateway/src/plugin/bridge.rs index 40f0823d..b03e3509 100644 --- a/crates/tw-gateway/src/plugin/bridge.rs +++ b/crates/tw-gateway/src/plugin/bridge.rs @@ -38,6 +38,11 @@ pub struct Bridge { /// `values` 的下标,按值的字节序排:一段尾巴是不是哪个值的开头,二分查一次(见 /// [`Bridge::hold_from`]) by_bytes: Vec, + /// `values` 的下标,按占位符的字节序排:插件交回来的一段结尾是不是哪个占位符的开头, + /// 二分查一次(见 [`Bridge::unfinished_from`]) + by_placeholder: Vec, + /// 最长的占位符有多长:结尾扣住的不会比它长 + longest_placeholder: usize, } impl Bridge { @@ -48,6 +53,8 @@ impl Bridge { values: Vec::new(), lengths: Lengths::default(), by_bytes: Vec::new(), + by_placeholder: Vec::new(), + longest_placeholder: 0, } } @@ -106,8 +113,12 @@ impl Bridge { values.sort_by(|a, b| b.0.len().cmp(&a.0.len()).then_with(|| a.0.cmp(&b.0))); let mut by_bytes: Vec = (0..values.len()).collect(); by_bytes.sort_unstable_by(|&a, &b| values[a].0.cmp(&values[b].0)); + let mut by_placeholder: Vec = (0..values.len()).collect(); + by_placeholder.sort_unstable_by(|&a, &b| values[a].1.cmp(&values[b].1)); + self.longest_placeholder = values.iter().map(|(_, p)| p.len()).max().unwrap_or(0); self.lengths = Lengths::of(&values); self.by_bytes = by_bytes; + self.by_placeholder = by_placeholder; self.values = values; } @@ -265,6 +276,31 @@ impl Bridge { o.len() > tail.len() && o.starts_with(tail) }) } + + /// 流式插件交回来的一段,从哪个字节起是账里某个占位符**没写完**的开头:插件从占位符 + /// 中间切开它交出的文字时,前后两截各自都换不回原值,结尾这半个要扣住,和它交出的下一段 + /// 拼起来再换。 + /// + /// 返回 `s.len()` 是没有要扣的。只认账里的占位符(同 [`tw_guard::redact::stream`]): + /// `<<` 在 C++ 代码、heredoc 里到处都是,不能见一个扣一个。扣住的因此不会比最长的占位符 + /// 长;切点在占位符开头那个 ASCII 字节上,总在字符边界上。 + pub fn unfinished_from(&self, s: &str) -> usize { + let b = s.as_bytes(); + (b.len().saturating_sub(self.longest_placeholder)..b.len()) + .find(|&at| s.is_char_boundary(at) && self.starts_a_placeholder(&b[at..])) + .unwrap_or(b.len()) + } + + /// `tail` 是不是账里某个占位符的**真**前缀(同 [`Bridge::starts_a_value`]) + fn starts_a_placeholder(&self, tail: &[u8]) -> bool { + let first = self + .by_placeholder + .partition_point(|&v| self.values[v].1.as_bytes() < tail); + self.by_placeholder[first..].iter().take(2).any(|&v| { + let p = self.values[v].1.as_bytes(); + p.len() > tail.len() && p.starts_with(tail) + }) + } } /// 账里的值按长度分组,在一段文字里一遍找出它们出现的每一处(见 [`Bridge::hide_known`])。 @@ -481,6 +517,32 @@ mod tests { assert_eq!(b.hold_from("什么都不像"), "什么都不像".len()); } + #[test] + fn only_the_unfinished_start_of_a_placeholder_in_the_ledger_is_held() { + let mut b = Bridge::new(rules()); + b.learn( + format!("{{\"messages\":[{{\"content\":\"{KEY}\"}}]}}").as_bytes(), + crate::guard::TEST_SHAPE, + ); + for (s, held) in [ + ("用 <", "<"), + ("用 <", "<"), + ("<<>", ""), + // 账里没有的占位符、C++ 的 `<<` 不扣 + ("用 < Bridge { use tw_guard::redact::rules::{Hit, Rule}; diff --git a/crates/tw-gateway/src/plugin/reply/mod.rs b/crates/tw-gateway/src/plugin/reply/mod.rs index 09994fb6..49e815f5 100644 --- a/crates/tw-gateway/src/plugin/reply/mod.rs +++ b/crates/tw-gateway/src/plugin/reply/mod.rs @@ -20,7 +20,8 @@ //! //! - **文字**按块交:整块模式攒齐一块再交一次,交回来的才发给客户端;流式模式每段 //! 增量交一次,交回什么现在就发什么(空串是先扣着),块结束时调 `onReplyTextEnd` -//! 把扣着的补上。几个插件串起来,前一个交出的是后一个收到的。 +//! 把扣着的补上。交回来的结尾是半个占位符的,那半个等它交出下一段拼齐了再换回原值。 +//! 几个插件串起来,前一个交出的是后一个收到的。 //! - **工具调用**攒到完整再交:不变、换成别的(一个或几个)、去掉。换出来的按客户端 //! 的格式写成完整的调用,后面块的序号跟着挪。 //! - 推理内容不交给插件,原样发。 @@ -112,6 +113,9 @@ struct StageLane { /// 流式模式:上一次交出非空的东西之后收到的原文。插件半路出错又是跳过时,它 /// 扣着的那些字从这里补回去,模型说过的话不丢 pending: String, + /// 流式模式:插件交回来的结尾是半个占位符(它从占位符中间切开了交出的文字),先扣着, + /// 和它交出的下一段拼起来再换回原值(见 [`Bridge::unfinished_from`]) + unrevealed: String, } #[derive(Default, Clone, Copy)] @@ -433,8 +437,12 @@ impl Chain { let hidden = self.bridge.hide(&send); let given = hidden.clone(); self.stages[i].counts.text_calls += 1; + let carrying = self.stages[i] + .lanes + .get(&lane) + .is_some_and(|l| !l.unrevealed.is_empty()); match self.invoke(i, move |r| r.on_text(&given)).await { - Ok(None) => { + Ok(None) if !carrying => { self.stages[i] .lanes .entry(lane) @@ -443,29 +451,49 @@ impl Chain { .clear(); Ok(Some(send)) } - Ok(Some(s)) => { + // 没改就是交回了交给它的那样:前一段结尾扣着半个占位符时,接在它后面一起换 + Ok(s) => { + let s = s.unwrap_or_else(|| hidden.clone()); if s != hidden { self.stages[i].counts.text_changed += 1; } - let out = self.bridge.reveal(&s); + let out = self.reveal(i, lane, &s, false); let l = self.stages[i].lanes.entry(lane).or_default(); - if out.is_empty() { + // 交出了东西(哪怕整段都还扣在半个占位符里):收到的原文有了着落 + if s.is_empty() { l.pending.push_str(&send); - Ok(None) } else { l.pending.clear(); - Ok(Some(out)) } + Ok((!out.is_empty()).then_some(out)) } Err(e) => { self.fail(i, e)?; - // 跳过:它扣着的、这一段、还没交给它的尾巴,原样往下走 + // 跳过:它交出的半个占位符、它扣着的、这一段、还没交给它的尾巴,原样往下走 let l = self.stages[i].lanes.remove(&lane).unwrap_or_default(); - Ok(Some(format!("{}{send}{}", l.pending, l.buf))) + Ok(Some(format!( + "{}{}{send}{}", + l.unrevealed, l.pending, l.buf + ))) } } } + /// 流式插件在这一块里交回来的一段,换回原值。结尾是半个占位符的先扣着,和它交出的 + /// 下一段拼起来再换。`end`:块结束了,扣着的一起放出去,凑不完整的原样放 + fn reveal(&mut self, i: usize, lane: Lane, s: &str, end: bool) -> String { + let l = self.stages[i].lanes.entry(lane).or_default(); + let mut joined = std::mem::take(&mut l.unrevealed); + joined.push_str(s); + let cut = if end { + joined.len() + } else { + self.bridge.unfinished_from(&joined) + }; + l.unrevealed = joined.split_off(cut); + self.bridge.reveal(&joined) + } + /// 一块文字的一段增量。返回此刻该发给客户端的(可能是空的:插件扣着) pub async fn text(&mut self, lane: Lane, piece: &str) -> Result { let mut pieces = vec![piece.to_string()]; @@ -506,14 +534,14 @@ impl Chain { if !self.live(i) { // 半路被拿掉的:它攒着的原样放出来 if let Some(l) = self.stages[i].lanes.remove(&lane) { - out.push(format!("{}{}", l.pending, l.buf)); + out.push(format!("{}{}{}", l.unrevealed, l.pending, l.buf)); } carry = out; continue; } - let l = self.stages[i].lanes.remove(&lane).unwrap_or_default(); match self.stages[i].mode { ReplyMode::Block => { + let l = self.stages[i].lanes.remove(&lane).unwrap_or_default(); if !l.buf.is_empty() { let whole = l.buf; let hidden = self.bridge.hide(&whole); @@ -535,12 +563,19 @@ impl Chain { } } ReplyMode::Stream => { - if !l.buf.is_empty() { + // 这一块的账留到最后:扣着的半个占位符、出错时要补回去的原文都在上面 + let buf = self.stages[i] + .lanes + .get_mut(&lane) + .map(|l| std::mem::take(&mut l.buf)) + .unwrap_or_default(); + if !buf.is_empty() { // 扣着的尾巴到头了:不会再长成别的,交出去 - if let Some(o) = self.stream_call(i, lane, l.buf).await? { + if let Some(o) = self.stream_call(i, lane, buf).await? { out.push(o); } } + let mut last = String::new(); if self.live(i) && self.stages[i].text_end { match self.invoke(i, |r| r.on_text_end()).await { Ok(None) => {} @@ -548,19 +583,17 @@ impl Chain { if !s.is_empty() { self.stages[i].counts.text_changed += 1; } - out.push(self.bridge.reveal(&s)); + last = s; } Err(e) => { - let pending = self.stages[i] - .lanes - .remove(&lane) - .map(|l| l.pending) - .unwrap_or_default(); + let l = self.stages[i].lanes.remove(&lane).unwrap_or_default(); self.fail(i, e)?; - out.push(pending); + out.push(format!("{}{}", l.unrevealed, l.pending)); } } } + // 扣着的半个占位符接上最后这一段换回去,凑不完整的原样放出去 + out.push(self.reveal(i, lane, &last, true)); self.stages[i].lanes.remove(&lane); } } diff --git a/crates/tw-gateway/src/plugin/reply/tests/mod.rs b/crates/tw-gateway/src/plugin/reply/tests/mod.rs index 746d2127..33c48fdf 100644 --- a/crates/tw-gateway/src/plugin/reply/tests/mod.rs +++ b/crates/tw-gateway/src/plugin/reply/tests/mod.rs @@ -452,6 +452,209 @@ async fn a_failing_plugin_cuts_under_reject_and_steps_aside_under_skip() { assert_eq!(anthropic_blocks(&out)[1].2, "HELLO 世界"); } +// ───────────────────────────────────────────────────────── 切开的占位符 + +/// 流式:每段只交出除最后 `n` 个字以外的,扣着的块结束时放出来。交出去的切口会落在 +/// 占位符中间 +fn hold_last(n: usize) -> Double { + Double::new("hold-last") + .permit(&[Permission::ReplyText]) + .mode(ReplyMode::Stream) + .on_reply(true, true, false, move |_| { + let buf = Arc::new(Mutex::new(String::new())); + let (a, b) = (buf.clone(), buf); + Ok(Box::new(Closures { + text: Box::new(move |t| { + let mut buf = a.lock().unwrap(); + buf.push_str(t); + let cut = buf.char_indices().rev().nth(n - 1).map_or(0, |(i, _)| i); + Invocation::ok(Some(buf.drain(..cut).collect())) + }), + end: Box::new(move || { + Invocation::ok(Some(std::mem::take(&mut *b.lock().unwrap()))) + }), + tool: Box::new(|_| Invocation::ok(ToolCallOutcome::Unchanged)), + })) + }) +} + +/// 流式:记下每次收到的,原样放过 +fn spy(seen: Arc>>) -> Double { + Double::new("spy") + .permit(&[Permission::ReplyText]) + .mode(ReplyMode::Stream) + .on_reply(true, false, false, move |_| { + let seen = seen.clone(); + Ok(Box::new(Closures { + text: Box::new(move |t| { + seen.lock().unwrap().push(t.to_string()); + Invocation::ok(None) + }), + end: Box::new(|| Invocation::ok(None)), + tool: Box::new(|_| Invocation::ok(ToolCallOutcome::Unchanged)), + })) + }) +} + +/// 只有一块文字的 Anthropic 流,按这几段增量发 +fn anthropic_text(deltas: &[&str]) -> String { + let mut frames = vec![ev( + "content_block_start", + json!({"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}), + )]; + for d in deltas { + frames.push(ev( + "content_block_delta", + json!({"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text": d}}), + )); + } + frames.push(ev( + "content_block_stop", + json!({"type":"content_block_stop","index":0}), + )); + frames.concat() +} + +/// 请求里有 `KEY`:回答里出现时插件看到的是 `<>` +fn key_request() -> Value { + json!({ "messages": [{ "role": "user", "content": format!("key {KEY}") }] }) +} + +#[tokio::test] +async fn a_placeholder_a_stream_plugin_splits_is_restored_from_both_halves() { + let want = format!("use {KEY} now"); + let (head, tail) = (format!("use {}", &KEY[..12]), format!("{} now", &KEY[12..])); + let splits: [Vec<&str>; 3] = [vec![&want], vec!["use ", KEY, " now"], vec![&head, &tail]]; + for deltas in &splits { + let input = anthropic_text(deltas); + for plugins in [vec![hold_last(5)], vec![hold_last(5), hold_last(7)]] { + let n = plugins.len(); + for step in [3, 4096] { + let mut s = Stream::new( + chain_of(Dialect::Anthropic, plugins.clone(), &key_request()).await, + Framing::Sse, + ); + let (out, err) = run(&mut s, &input, step).await; + assert!(err.is_none()); + assert_eq!( + anthropic_blocks(&out)[0].2, + want, + "{deltas:?}, {n} plugins, step {step}" + ); + } + } + } +} + +#[tokio::test] +async fn the_next_plugin_in_the_chain_sees_whole_placeholders() { + let seen = Arc::new(Mutex::new(Vec::new())); + let input = anthropic_text(&["use ", KEY, " now"]); + let mut s = Stream::new( + chain_of( + Dialect::Anthropic, + vec![hold_last(5), spy(seen.clone())], + &key_request(), + ) + .await, + Framing::Sse, + ); + let (out, _) = run(&mut s, &input, 4096).await; + assert_eq!(anthropic_blocks(&out)[0].2, format!("use {KEY} now")); + let seen = seen.lock().unwrap().clone(); + assert_eq!(seen.concat(), "use <> now"); + // 每一段里的占位符都是整的 + for t in &seen { + assert_eq!( + t.matches("<<").count(), + t.matches("<>").count(), + "{seen:?}" + ); + } +} + +#[tokio::test] +async fn text_given_back_when_a_plugin_is_skipped_comes_out_restored() { + let want = format!("use {KEY} now"); + let input = anthropic_text(&["use ", KEY, " now"]); + // 后一个插件头一段就出错:前一个切开交出的照样拼回原值 + let boom = Double::new("boom") + .permit(&[Permission::ReplyText]) + .mode(ReplyMode::Stream) + .on_reply(true, false, false, |_| { + Ok(Box::new(Closures { + text: Box::new(|_| Invocation::err(RunError::CpuLimit)), + end: Box::new(|| Invocation::ok(None)), + tool: Box::new(|_| Invocation::ok(ToolCallOutcome::Unchanged)), + })) + }); + let mut chain = chain_of(Dialect::Anthropic, vec![hold_last(5), boom], &key_request()).await; + chain.stages[1].on_error = OnError::Skip; + let mut s = Stream::new(chain, Framing::Sse); + let (out, err) = run(&mut s, &input, 4096).await; + assert!(err.is_none()); + assert_eq!(anthropic_blocks(&out)[0].2, want); +} + +#[tokio::test] +async fn a_stream_plugin_failing_at_the_end_of_a_block_gives_back_what_it_held() { + let want = format!("use {KEY} now"); + let input = anthropic_text(&["use ", KEY, " now"]); + // 一直扣着、到块结束时才出错:扣着的那些原文补回来 + let late = Double::new("late") + .permit(&[Permission::ReplyText]) + .mode(ReplyMode::Stream) + .on_reply(true, true, false, |_| { + Ok(Box::new(Closures { + text: Box::new(|_| Invocation::ok(Some(String::new()))), + end: Box::new(|| Invocation::err(RunError::CpuLimit)), + tool: Box::new(|_| Invocation::ok(ToolCallOutcome::Unchanged)), + })) + }); + let mut chain = chain_of(Dialect::Anthropic, vec![late], &key_request()).await; + chain.stages[0].on_error = OnError::Skip; + let mut s = Stream::new(chain, Framing::Sse); + let (out, err) = run(&mut s, &input, 4096).await; + assert!(err.is_none()); + assert_eq!(anthropic_blocks(&out)[0].2, want); +} + +#[tokio::test] +async fn whole_bodies_restore_a_placeholder_a_stream_plugin_splits() { + let t = format!("use {KEY} now"); + let cases = [ + ( + Dialect::Anthropic, + json!({"id":"m","type":"message","role":"assistant", + "content":[{"type":"text","text":t}],"stop_reason":"end_turn"}), + ), + ( + Dialect::Chat, + json!({"id":"c","object":"chat.completion","choices":[{"index":0, + "message":{"role":"assistant","content":t},"finish_reason":"stop"}]}), + ), + ( + Dialect::Responses, + json!({"id":"r","object":"response","status":"completed","output":[ + {"type":"message","id":"msg","role":"assistant","content":[{"type":"output_text","text":t}]} + ]}), + ), + ( + Dialect::Gemini, + json!({"candidates":[{"content":{"role":"model","parts":[{"text":t}]},"finishReason":"STOP"}]}), + ), + ]; + for (d, body) in cases { + let mut chain = chain_of(d, vec![hold_last(5), hold_last(7)], &key_request()).await; + let out = whole(&mut chain, body.to_string().as_bytes()) + .await + .unwrap(); + let text = String::from_utf8(out).unwrap(); + assert!(text.contains(&t), "{d:?}: {text}"); + assert!(!text.contains("TW_SECRET"), "{d:?}: {text}"); + } +} + // ───────────────────────────────────────────────────────── Chat fn chat_stream() -> String { From 01010651c29d0bb5ce65d9733f53a7bcbdd01c44 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:07:15 +0800 Subject: [PATCH 2/2] Give back a skipped plugin's held text in another open block in order When several text blocks are open at once and a stream-mode plugin is skipped after failing in one of them, later pieces of the other blocks went straight through while what the plugin was still holding in those blocks (its held text, a held half placeholder, the tail kept back so a secret is not handed over in halves) only came out when the block ended, after the later pieces. That held text now goes out in front of the next piece of its block. Co-Authored-By: Claude Opus 5.5 --- crates/tw-gateway/src/plugin/reply/mod.rs | 6 +- .../tw-gateway/src/plugin/reply/tests/mod.rs | 60 +++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/crates/tw-gateway/src/plugin/reply/mod.rs b/crates/tw-gateway/src/plugin/reply/mod.rs index 49e815f5..c34d2af8 100644 --- a/crates/tw-gateway/src/plugin/reply/mod.rs +++ b/crates/tw-gateway/src/plugin/reply/mod.rs @@ -403,7 +403,11 @@ impl Chain { p: String, ) -> Result, GatewayError> { if !self.live(i) { - return Ok(Some(p)); + // 在别的块里出错被拿掉的:这一块里它攒着的原样接在这一段前面放出来 + return Ok(Some(match self.stages[i].lanes.remove(&lane) { + Some(l) => format!("{}{}{}{p}", l.unrevealed, l.pending, l.buf), + None => p, + })); } if self.stages[i].mode == ReplyMode::Block { self.stages[i] diff --git a/crates/tw-gateway/src/plugin/reply/tests/mod.rs b/crates/tw-gateway/src/plugin/reply/tests/mod.rs index 33c48fdf..7094708b 100644 --- a/crates/tw-gateway/src/plugin/reply/tests/mod.rs +++ b/crates/tw-gateway/src/plugin/reply/tests/mod.rs @@ -619,6 +619,66 @@ async fn a_stream_plugin_failing_at_the_end_of_a_block_gives_back_what_it_held() assert_eq!(anthropic_blocks(&out)[0].2, want); } +#[tokio::test] +async fn a_plugin_skipped_in_one_block_gives_back_another_open_blocks_text_in_order() { + // 两块文字同时开着、增量交替来。插件一直扣着,在 0 号块出错跳过:1 号块里它扣着的 + // (连同为了不交出半截密钥先扣着的尾巴)排在 1 号块后来的段前面,一个字不丢 + let fails_on_a2 = Double::new("fails-on-a2") + .permit(&[Permission::ReplyText]) + .mode(ReplyMode::Stream) + .on_reply(true, false, false, |_| { + Ok(Box::new(Closures { + text: Box::new(|t| { + if t.contains("A2") { + Invocation::err(RunError::CpuLimit) + } else { + Invocation::ok(Some(String::new())) + } + }), + end: Box::new(|| Invocation::ok(None)), + tool: Box::new(|_| Invocation::ok(ToolCallOutcome::Unchanged)), + })) + }); + let start = |i: u64| { + ev( + "content_block_start", + json!({"type":"content_block_start","index":i,"content_block":{"type":"text","text":""}}), + ) + }; + let delta = |i: u64, t: &str| { + ev( + "content_block_delta", + json!({"type":"content_block_delta","index":i,"delta":{"type":"text_delta","text":t}}), + ) + }; + let stop = |i: u64| { + ev( + "content_block_stop", + json!({"type":"content_block_stop","index":i}), + ) + }; + let input = [ + start(0), + start(1), + delta(1, "B1 "), + delta(0, "A1 "), + delta(1, &format!("B2 {}", &KEY[..12])), + delta(0, "A2 "), + delta(1, &format!("{} B3", &KEY[12..])), + stop(0), + stop(1), + ] + .concat(); + let mut chain = chain_of(Dialect::Anthropic, vec![fails_on_a2], &key_request()).await; + chain.stages[0].on_error = OnError::Skip; + let mut s = Stream::new(chain, Framing::Sse); + let (out, err) = run(&mut s, &input, 4096).await; + assert!(err.is_none()); + let blocks = anthropic_blocks(&out); + assert_eq!(blocks[0].2, "A1 A2 "); + assert_eq!(blocks[1].2, format!("B1 B2 {KEY} B3")); +} + #[tokio::test] async fn whole_bodies_restore_a_placeholder_a_stream_plugin_splits() { let t = format!("use {KEY} now");