diff --git a/app/src/ai/blocklist/controller/response_stream.rs b/app/src/ai/blocklist/controller/response_stream.rs index aeca0e22d9b..5e00537195d 100644 --- a/app/src/ai/blocklist/controller/response_stream.rs +++ b/app/src/ai/blocklist/controller/response_stream.rs @@ -535,11 +535,12 @@ impl ResponseStream { RecoveryAction::Fail(reason) => { log::warn!( "MultiAgent request failed; not recovering: recovery={} reason={} \ - attempt={}/{MAX_RECOVERY_ATTEMPTS} failed_request={} - Error: {error:?}", + attempt={}/{MAX_RECOVERY_ATTEMPTS} failed_request={} {} - Error: {error:?}", action.log_label(), reason.log_label(), self.recovery.attempts_used(), self.failed_request_label(), + self.request_context_label(), ); self.error_event_emitted = true; self.report_request_failure(error, is_online, self.recovery.attempts_used()); @@ -556,13 +557,26 @@ impl ResponseStream { fn log_recovery(&self, action: RecoveryAction, wait: &str, error: &Arc) { log::warn!( "MultiAgent request failed; recovering: recovery={} \ - attempt={}/{MAX_RECOVERY_ATTEMPTS} wait={wait} failed_request={} - Error: {error:?}", + attempt={}/{MAX_RECOVERY_ATTEMPTS} wait={wait} failed_request={} {} - Error: {error:?}", action.log_label(), self.recovery.attempts_used() + 1, self.failed_request_label(), + self.request_context_label(), ); } + /// The model the failed request targeted and whether it carried user-provided credentials, + /// so a transport failure can be attributed to hosted or bring-your-own-key routing. + /// Reports presence only, never credential values. + fn request_context_label(&self) -> String { + format!( + "model={} user_api_keys={} custom_model_providers={}", + self.params.model, + self.params.api_keys.is_some(), + self.params.custom_model_providers.is_some(), + ) + } + /// Sends the request for `request_id`. When the request's model is served by /// the connected Grok subscription or may route to Gemini Enterprise, and /// that credential is already past hard expiry, this first blocks on a diff --git a/app/src/ai/blocklist/controller/response_stream_tests.rs b/app/src/ai/blocklist/controller/response_stream_tests.rs index baa558e1573..f31b5a8a139 100644 --- a/app/src/ai/blocklist/controller/response_stream_tests.rs +++ b/app/src/ai/blocklist/controller/response_stream_tests.rs @@ -318,3 +318,30 @@ fn geap_refresh_does_not_add_credentials_to_a_keyless_request() { assert!(params.api_keys.is_none()); } + +#[cfg(not(target_family = "wasm"))] +#[test] +fn request_context_label_reports_a_hosted_request_without_user_credentials() { + let stream = ResponseStream::new_for_test(ResponseStreamId::new_for_test()); + + assert_eq!( + stream.request_context_label(), + "model=test-model user_api_keys=false custom_model_providers=false" + ); +} + +#[cfg(not(target_family = "wasm"))] +#[test] +fn request_context_label_reports_attached_credentials_without_leaking_them() { + let mut stream = ResponseStream::new_for_test(ResponseStreamId::new_for_test()); + stream.params = params_with_geap_token("super-secret-token"); + stream.params.custom_model_providers = Some(Default::default()); + + let label = stream.request_context_label(); + + assert_eq!( + label, + "model=test-model user_api_keys=true custom_model_providers=true" + ); + assert!(!label.contains("super-secret-token")); +}