systemprompt_api/services/gateway/
signature_cache.rs1use std::collections::HashMap;
27use std::sync::{Mutex, OnceLock, PoisonError};
28use std::time::{Duration, Instant};
29
30use systemprompt_identifiers::GatewayConversationId;
31use systemprompt_models::profile::WireProtocol;
32use systemprompt_models::wire::canonical::{CanonicalContent, CanonicalRequest, CanonicalResponse};
33
34const TTL: Duration = Duration::from_hours(1);
35const MAX_ENTRIES: usize = 10_000;
36const HYDRATION_TOTAL: &str = "gateway_signature_hydration_total";
37const CAPTURE_SKIPPED_TOTAL: &str = "gateway_signature_capture_skipped_total";
38
39struct Entry {
40 signature: String,
41 expires_at: Instant,
42}
43
44type Key = (GatewayConversationId, String);
45
46pub struct ThoughtSignatureCache {
47 entries: Mutex<HashMap<Key, Entry>>,
48 ttl: Duration,
49 max_entries: usize,
50}
51
52impl std::fmt::Debug for ThoughtSignatureCache {
53 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54 f.debug_struct("ThoughtSignatureCache")
55 .field("ttl", &self.ttl)
56 .field("max_entries", &self.max_entries)
57 .finish_non_exhaustive()
58 }
59}
60
61impl ThoughtSignatureCache {
62 pub fn global() -> &'static Self {
63 static CACHE: OnceLock<ThoughtSignatureCache> = OnceLock::new();
64 CACHE.get_or_init(|| Self::new(TTL, MAX_ENTRIES))
65 }
66
67 #[must_use]
68 pub fn new(ttl: Duration, max_entries: usize) -> Self {
69 Self {
70 entries: Mutex::new(HashMap::new()),
71 ttl,
72 max_entries,
73 }
74 }
75
76 pub fn store(&self, conversation: &GatewayConversationId, tool_use_id: &str, signature: &str) {
77 let key = (conversation.clone(), tool_use_id.to_owned());
78 let mut entries = self.entries.lock().unwrap_or_else(PoisonError::into_inner);
79 let now = Instant::now();
80 if !entries.contains_key(&key) && entries.len() >= self.max_entries {
81 entries.retain(|_, e| e.expires_at > now);
82 if entries.len() >= self.max_entries
83 && let Some(oldest) = entries
84 .iter()
85 .min_by_key(|(_, e)| e.expires_at)
86 .map(|(k, _)| k.clone())
87 {
88 entries.remove(&oldest);
89 }
90 }
91 entries.insert(
92 key,
93 Entry {
94 signature: signature.to_owned(),
95 expires_at: now + self.ttl,
96 },
97 );
98 }
99
100 pub fn lookup(
101 &self,
102 conversation: &GatewayConversationId,
103 tool_use_id: &str,
104 ) -> Option<String> {
105 let key = (conversation.clone(), tool_use_id.to_owned());
106 let now = Instant::now();
107 let mut entries = self.entries.lock().unwrap_or_else(PoisonError::into_inner);
108 let entry = entries.get_mut(&key)?;
109 if entry.expires_at <= now {
110 entries.remove(&key);
111 return None;
112 }
113 entry.expires_at = now + self.ttl;
114 let signature = entry.signature.clone();
115 drop(entries);
116 Some(signature)
117 }
118
119 pub fn store_from_response(
120 &self,
121 conversation: &GatewayConversationId,
122 response: &CanonicalResponse,
123 ) {
124 for content in &response.content {
125 if let CanonicalContent::ToolUse {
126 id,
127 signature: Some(signature),
128 ..
129 } = content
130 {
131 self.store(conversation, id, signature);
132 }
133 }
134 }
135
136 pub fn hydrate_request(
137 &self,
138 conversation: &GatewayConversationId,
139 request: &mut CanonicalRequest,
140 wire: Option<WireProtocol>,
141 ) {
142 let model = request.model.clone();
143 let signatures_required = wire == Some(WireProtocol::Gemini);
144 for message in &mut request.messages {
145 for content in &mut message.content {
146 let CanonicalContent::ToolUse { id, signature, .. } = content else {
147 continue;
148 };
149 match signature {
150 Some(sig) => self.store(conversation, id, sig),
151 None => match self.lookup(conversation, id) {
152 Some(cached) => {
153 *signature = Some(cached);
154 if signatures_required {
155 metrics::counter!(HYDRATION_TOTAL, "outcome" => "hit").increment(1);
156 }
157 },
158 None => {
159 if signatures_required {
160 metrics::counter!(HYDRATION_TOTAL, "outcome" => "miss")
161 .increment(1);
162 tracing::warn!(
163 conversation = %conversation,
164 tool_use_id = %id,
165 model = %model,
166 "no cached thought signature for tool_use; upstream may reject the turn"
167 );
168 }
169 },
170 },
171 }
172 }
173 }
174 }
175
176 #[cfg(feature = "test-api")]
177 #[expect(
178 clippy::panic,
179 reason = "test-only seam, compiled out unless `test-api` is enabled"
180 )]
181 pub fn poison_lock(&self) {
182 let _guard = self.entries.lock().unwrap_or_else(PoisonError::into_inner);
183 panic!("poisoning the signature cache lock");
184 }
185
186 #[must_use]
187 pub fn signed_tool_use_count(response: &CanonicalResponse) -> usize {
188 response
189 .content
190 .iter()
191 .filter(|c| {
192 matches!(
193 c,
194 CanonicalContent::ToolUse {
195 signature: Some(_),
196 ..
197 }
198 )
199 })
200 .count()
201 }
202
203 pub fn note_uncacheable_response(response: &CanonicalResponse, reason: &'static str) {
204 let signed = Self::signed_tool_use_count(response);
205 if signed == 0 {
206 return;
207 }
208 metrics::counter!(CAPTURE_SKIPPED_TOTAL, "reason" => reason).increment(1);
209 tracing::warn!(
210 reason,
211 signed_tool_use_blocks = signed,
212 "thought signatures could not be cached; a later turn in this conversation will miss"
213 );
214 }
215}