1use std::fmt;
29use std::sync::Arc;
30
31use crate::LlmError;
32use crate::any::AnyProvider;
33use crate::provider::{
34 ChatExtras, ChatResponse, ChatStream, LlmProvider, Message, MessagePart, ToolDefinition,
35};
36use crate::provider_dyn::LlmProviderDyn;
37
38pub trait OutboundMasker: fmt::Debug + Send + Sync {
43 fn mask(&self, text: &str) -> Option<String>;
48}
49
50#[derive(Clone)]
75pub struct MaskedProvider {
76 pub(crate) inner: Box<AnyProvider>,
77 pub(crate) masker: Arc<dyn OutboundMasker>,
78 applied_count: Arc<std::sync::atomic::AtomicU64>,
81}
82
83impl fmt::Debug for MaskedProvider {
84 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85 f.debug_struct("MaskedProvider")
86 .field("inner", &self.inner)
87 .finish_non_exhaustive()
88 }
89}
90
91impl MaskedProvider {
92 #[must_use]
94 pub fn new(inner: AnyProvider, masker: Arc<dyn OutboundMasker>) -> Self {
95 Self {
96 inner: Box::new(inner),
97 masker,
98 applied_count: Arc::new(std::sync::atomic::AtomicU64::new(0)),
99 }
100 }
101
102 #[must_use]
104 pub fn inner(&self) -> &AnyProvider {
105 &self.inner
106 }
107
108 #[must_use]
111 pub fn applied_count(&self) -> u64 {
112 self.applied_count
113 .load(std::sync::atomic::Ordering::Relaxed)
114 }
115
116 pub(crate) fn mask_messages(&self, messages: &[Message]) -> Option<Vec<Message>> {
119 let result = mask_messages(self.masker.as_ref(), messages);
120 if result.is_some() {
121 self.applied_count
122 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
123 }
124 result
125 }
126}
127
128#[must_use]
151pub fn mask_messages(masker: &dyn OutboundMasker, messages: &[Message]) -> Option<Vec<Message>> {
152 let any_candidate = messages.iter().any(|m| {
153 if m.parts.is_empty() {
154 masker.mask(&m.content).is_some()
155 } else {
156 m.parts
157 .iter()
158 .filter_map(part_text_ref)
159 .any(|text| masker.mask(text).is_some())
160 }
161 });
162 if !any_candidate {
163 return None;
164 }
165
166 let mut any_masked = false;
167 let result: Vec<Message> = messages
168 .iter()
169 .map(|original| {
170 let mut msg = original.clone();
171 if msg.parts.is_empty() {
172 if let Some(masked_content) = masker.mask(&msg.content) {
173 any_masked = true;
174 msg.content = masked_content;
175 }
176 } else {
177 let mut changed = false;
178 for part in &mut msg.parts {
179 if let Some(text) = part_text_mut(part)
180 && let Some(masked_text) = masker.mask(text)
181 {
182 *text = masked_text;
183 changed = true;
184 }
185 }
186 if changed {
187 any_masked = true;
188 msg.rebuild_content();
189 }
190 }
191 msg
192 })
193 .collect();
194 any_masked.then_some(result)
195}
196
197fn part_text_mut(part: &mut MessagePart) -> Option<&mut String> {
202 match part {
203 MessagePart::Text { text }
204 | MessagePart::Recall { text }
205 | MessagePart::CodeContext { text }
206 | MessagePart::Summary { text }
207 | MessagePart::CrossSession { text } => Some(text),
208 MessagePart::ToolOutput { body, .. } => Some(body),
209 MessagePart::ToolResult { content, .. } => Some(content),
210 MessagePart::Compaction { summary } => Some(summary),
211 _ => None,
212 }
213}
214
215fn part_text_ref(part: &MessagePart) -> Option<&str> {
217 match part {
218 MessagePart::Text { text }
219 | MessagePart::Recall { text }
220 | MessagePart::CodeContext { text }
221 | MessagePart::Summary { text }
222 | MessagePart::CrossSession { text } => Some(text.as_str()),
223 MessagePart::ToolOutput { body, .. } => Some(body.as_str()),
224 MessagePart::ToolResult { content, .. } => Some(content.as_str()),
225 MessagePart::Compaction { summary } => Some(summary.as_str()),
226 _ => None,
227 }
228}
229
230impl LlmProvider for MaskedProvider {
231 fn context_window(&self) -> Option<usize> {
232 LlmProvider::context_window(self.inner.as_ref())
233 }
234
235 async fn chat(&self, messages: &[Message]) -> Result<String, LlmError> {
243 let masked = self.mask_messages(messages);
244 LlmProviderDyn::chat(self.inner.as_ref(), masked.as_deref().unwrap_or(messages)).await
245 }
246
247 async fn chat_with_extras(
248 &self,
249 messages: &[Message],
250 ) -> Result<(String, ChatExtras), LlmError> {
251 let masked = self.mask_messages(messages);
252 LlmProviderDyn::chat_with_extras(self.inner.as_ref(), masked.as_deref().unwrap_or(messages))
253 .await
254 }
255
256 async fn chat_stream(&self, messages: &[Message]) -> Result<ChatStream, LlmError> {
257 let masked = self.mask_messages(messages);
258 LlmProviderDyn::chat_stream(self.inner.as_ref(), masked.as_deref().unwrap_or(messages))
259 .await
260 }
261
262 fn supports_streaming(&self) -> bool {
263 LlmProvider::supports_streaming(self.inner.as_ref())
264 }
265
266 async fn embed(&self, text: &str) -> Result<Vec<f32>, LlmError> {
267 LlmProviderDyn::embed(self.inner.as_ref(), text).await
272 }
273
274 async fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, LlmError> {
275 LlmProviderDyn::embed_batch(self.inner.as_ref(), texts).await
276 }
277
278 fn supports_embeddings(&self) -> bool {
279 LlmProvider::supports_embeddings(self.inner.as_ref())
280 }
281
282 fn name(&self) -> &str {
283 LlmProvider::name(self.inner.as_ref())
284 }
285
286 fn model_identifier(&self) -> &str {
287 LlmProvider::model_identifier(self.inner.as_ref())
288 }
289
290 fn effective_model_identifier(&self) -> &str {
291 LlmProvider::effective_model_identifier(self.inner.as_ref())
292 }
293
294 fn supports_structured_output(&self) -> bool {
295 LlmProvider::supports_structured_output(self.inner.as_ref())
296 }
297
298 fn supports_vision(&self) -> bool {
299 LlmProvider::supports_vision(self.inner.as_ref())
300 }
301
302 fn supports_tool_use(&self) -> bool {
303 LlmProvider::supports_tool_use(self.inner.as_ref())
304 }
305
306 async fn chat_with_tools(
307 &self,
308 messages: &[Message],
309 tools: &[ToolDefinition],
310 ) -> Result<ChatResponse, LlmError> {
311 let masked = self.mask_messages(messages);
312 LlmProviderDyn::chat_with_tools(
313 self.inner.as_ref(),
314 masked.as_deref().unwrap_or(messages),
315 tools,
316 )
317 .await
318 }
319
320 fn last_cache_usage(&self) -> Option<(u64, u64)> {
321 LlmProvider::last_cache_usage(self.inner.as_ref())
322 }
323
324 fn last_usage(&self) -> Option<(u64, u64)> {
325 LlmProvider::last_usage(self.inner.as_ref())
326 }
327
328 fn last_reasoning_tokens(&self) -> Option<u64> {
329 LlmProvider::last_reasoning_tokens(self.inner.as_ref())
330 }
331
332 fn last_ttft_ms(&self) -> Option<u64> {
333 LlmProvider::last_ttft_ms(self.inner.as_ref())
334 }
335
336 fn debug_request_json(
337 &self,
338 messages: &[Message],
339 tools: &[ToolDefinition],
340 stream: bool,
341 ) -> serde_json::Value {
342 let masked = self.mask_messages(messages);
343 LlmProvider::debug_request_json(
344 self.inner.as_ref(),
345 masked.as_deref().unwrap_or(messages),
346 tools,
347 stream,
348 )
349 }
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355 use crate::mock::MockProvider;
356 use crate::provider::Role;
357
358 #[derive(Debug)]
359 struct FixedMasker;
360 impl OutboundMasker for FixedMasker {
361 fn mask(&self, text: &str) -> Option<String> {
362 if text.contains("SECRET_VALUE") {
363 Some(text.replace("SECRET_VALUE", "<MASKED>"))
364 } else {
365 None
366 }
367 }
368 }
369
370 fn masked(inner: AnyProvider) -> MaskedProvider {
371 MaskedProvider::new(inner, Arc::new(FixedMasker))
372 }
373
374 fn mock_any(responses: Vec<String>) -> AnyProvider {
375 AnyProvider::Mock(MockProvider::with_responses(responses))
376 }
377
378 #[tokio::test]
379 async fn chat_masks_flat_content() {
380 let (mock, recorded) = MockProvider::with_responses(vec!["ok".into()]).with_recording();
381 let mp = masked(AnyProvider::Mock(mock));
382 let messages = vec![Message::from_legacy(
383 Role::User,
384 "value is SECRET_VALUE here",
385 )];
386 LlmProvider::chat(&mp, &messages).await.unwrap();
387 let sent = recorded.lock().unwrap();
388 assert!(!sent[0][0].content.contains("SECRET_VALUE"));
389 assert!(sent[0][0].content.contains("<MASKED>"));
390 }
391
392 #[tokio::test]
393 async fn chat_with_no_match_forwards_unchanged() {
394 let (mock, recorded) = MockProvider::with_responses(vec!["ok".into()]).with_recording();
395 let mp = masked(AnyProvider::Mock(mock));
396 let messages = vec![Message::from_legacy(Role::User, "nothing sensitive")];
397 LlmProvider::chat(&mp, &messages).await.unwrap();
398 let sent = recorded.lock().unwrap();
399 assert_eq!(sent[0][0].content, "nothing sensitive");
400 }
401
402 #[derive(Debug)]
407 struct CountingMasker {
408 calls: std::sync::atomic::AtomicUsize,
409 }
410 impl CountingMasker {
411 fn new() -> Self {
412 Self {
413 calls: std::sync::atomic::AtomicUsize::new(0),
414 }
415 }
416 fn call_count(&self) -> usize {
417 self.calls.load(std::sync::atomic::Ordering::Relaxed)
418 }
419 }
420 impl OutboundMasker for CountingMasker {
421 fn mask(&self, text: &str) -> Option<String> {
422 self.calls
423 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
424 if text.contains("SECRET_VALUE") {
425 Some(text.replace("SECRET_VALUE", "<MASKED>"))
426 } else {
427 None
428 }
429 }
430 }
431
432 #[test]
433 fn mask_messages_no_match_returns_none_via_pre_scan_only() {
434 let masker = CountingMasker::new();
435 let messages: Vec<Message> = (0..25)
436 .map(|i| Message::from_legacy(Role::User, format!("clean message #{i}")))
437 .collect();
438
439 let result = mask_messages(&masker, &messages);
440
441 assert!(
442 result.is_none(),
443 "no registered secret anywhere — must return None"
444 );
445 assert_eq!(
446 masker.call_count(),
447 25,
448 "no-match path must call mask() exactly once per message (pre-scan only) — a \
449 higher count would mean a second clone-and-mask pass ran despite no match"
450 );
451 }
452
453 #[test]
454 fn mask_messages_finds_match_among_many_clean_messages() {
455 let masker = CountingMasker::new();
456 let mut messages: Vec<Message> = (0..10)
457 .map(|i| Message::from_legacy(Role::User, format!("clean message #{i}")))
458 .collect();
459 messages.push(Message::from_legacy(
460 Role::User,
461 "value is SECRET_VALUE here",
462 ));
463
464 let result = mask_messages(&masker, &messages).expect("one message matches");
465
466 assert_eq!(result.len(), 11);
467 assert!(!result[10].content.contains("SECRET_VALUE"));
468 assert!(result[10].content.contains("<MASKED>"));
469 assert_eq!(result[0].content, "clean message #0");
471 }
472
473 #[tokio::test]
474 async fn chat_masks_tool_result_content() {
475 let (mock, recorded) = MockProvider::with_responses(vec!["ok".into()]).with_recording();
476 let mp = masked(AnyProvider::Mock(mock));
477 let messages = vec![Message::from_parts(
478 Role::User,
479 vec![MessagePart::ToolResult {
480 tool_use_id: "id1".into(),
481 content: "tool printed SECRET_VALUE".into(),
482 is_error: false,
483 }],
484 )];
485 LlmProvider::chat(&mp, &messages).await.unwrap();
486 let sent = recorded.lock().unwrap();
487 let MessagePart::ToolResult { content, .. } = &sent[0][0].parts[0] else {
488 panic!("expected ToolResult part");
489 };
490 assert!(!content.contains("SECRET_VALUE"));
491 assert!(!sent[0][0].content.contains("SECRET_VALUE"));
493 }
494
495 #[tokio::test]
496 async fn chat_masks_tool_output_body() {
497 let (mock, recorded) = MockProvider::with_responses(vec!["ok".into()]).with_recording();
498 let mp = masked(AnyProvider::Mock(mock));
499 let messages = vec![Message::from_parts(
500 Role::User,
501 vec![MessagePart::ToolOutput {
502 tool_name: "bash".into(),
503 body: "env dump: SECRET_VALUE".into(),
504 compacted_at: None,
505 }],
506 )];
507 LlmProvider::chat(&mp, &messages).await.unwrap();
508 let sent = recorded.lock().unwrap();
509 let MessagePart::ToolOutput { body, .. } = &sent[0][0].parts[0] else {
510 panic!("expected ToolOutput part");
511 };
512 assert!(!body.contains("SECRET_VALUE"));
513 }
514
515 #[tokio::test]
516 async fn chat_never_touches_thinking_block() {
517 let (mock, recorded) = MockProvider::with_responses(vec!["ok".into()]).with_recording();
518 let mp = masked(AnyProvider::Mock(mock));
519 let messages = vec![Message::from_parts(
520 Role::Assistant,
521 vec![MessagePart::ThinkingBlock {
522 thinking: "reasoning mentions SECRET_VALUE".into(),
523 signature: "sig123".into(),
524 }],
525 )];
526 LlmProvider::chat(&mp, &messages).await.unwrap();
527 let sent = recorded.lock().unwrap();
528 let MessagePart::ThinkingBlock {
529 thinking,
530 signature,
531 } = &sent[0][0].parts[0]
532 else {
533 panic!("expected ThinkingBlock part");
534 };
535 assert!(thinking.contains("SECRET_VALUE"));
537 assert_eq!(signature, "sig123");
538 }
539
540 #[tokio::test]
541 async fn delegation_methods_pass_through_to_inner() {
542 let mp = masked(mock_any(vec![]));
543 assert_eq!(LlmProvider::name(&mp), "mock");
544 assert_eq!(
545 LlmProvider::context_window(&mp),
546 LlmProvider::context_window(mp.inner())
547 );
548 }
549
550 #[test]
551 fn debug_impl_does_not_panic() {
552 let mp = masked(mock_any(vec![]));
553 let s = format!("{mp:?}");
554 assert!(s.contains("MaskedProvider"));
555 }
556
557 #[test]
560 fn effective_model_identifier_resolves_through_masked_router() {
561 use crate::claude::ClaudeProvider;
562 use crate::router::RouterProvider;
563
564 let claude = AnyProvider::Claude(ClaudeProvider::new(
565 "k".into(),
566 "claude-3-opus-think".into(),
567 1024,
568 ));
569 let router = RouterProvider::new(vec![claude]);
570 *router.state.last_active_provider.lock() = Some("claude".to_owned());
571
572 let mp = masked(AnyProvider::Router(Box::new(router)));
573
574 assert_eq!(
575 LlmProvider::effective_model_identifier(&mp),
576 "claude-3-opus-think"
577 );
578 assert_ne!(LlmProvider::effective_model_identifier(&mp), "router");
579 }
580}