diff --git a/assets/settings/default.json b/assets/settings/default.json index 1896bddbdde..25084f304b6 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1487,6 +1487,9 @@ "diagnostics": true, // Send anonymized usage data like what languages you're using Zed with. "metrics": true, + // Allow sending requests to Anthropic models that cannot be offered with + // Zero Data Retention + "anthropic_retention": false, }, // Whether to disable all AI features in Zed. // diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 4e30ccc3294..466447d33fe 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -893,6 +893,33 @@ impl ContentBlock { } } + /// Updates a Markdown block in place from a streaming text `block`, reusing + /// the existing `Markdown` entity rather than recreating it. Appends only the + /// new suffix when the update is a continuation (the common streaming case), + /// otherwise re-sets the source. Returns `false` when an in-place update isn't + /// applicable, so the caller can fall back to replacing the block wholesale. + /// + /// Recreating the entity on every streamed snapshot causes the rendered + /// element to tear down and rebuild, which flickers badly. + pub fn update_text_in_place(&mut self, block: &acp::ContentBlock, cx: &mut App) -> bool { + let ContentBlock::Markdown { markdown } = self else { + return false; + }; + let acp::ContentBlock::Text(text_content) = block else { + return false; + }; + let new_content = &text_content.text; + markdown.update(cx, |markdown, cx| { + let current = markdown.source().to_string(); + match new_content.strip_prefix(¤t) { + Some("") => {} + Some(suffix) => markdown.append(suffix, cx), + None => markdown.reset(new_content.clone().into(), cx), + } + }); + true + } + fn decode_image( image_content: &acp::ImageContent, ) -> Option<(Arc, Option>)> { @@ -1061,6 +1088,17 @@ impl ToolCallContent { terminals: &HashMap>, cx: &mut App, ) -> Result { + // Update streaming text in place so the rendered markdown element is + // reused across snapshots instead of being recreated (which flickers). + if let ( + Self::ContentBlock(block), + acp::ToolCallContent::Content(acp::Content { content, .. }), + ) = (&mut *self, &new) + && block.update_text_in_place(content, cx) + { + return Ok(true); + } + let needs_update = match (&self, &new) { (Self::Diff(old_diff), acp::ToolCallContent::Diff(new_diff)) => { old_diff.read(cx).needs_update( @@ -1261,6 +1299,20 @@ pub struct RetryStatus { pub max_attempts: usize, pub started_at: Instant, pub duration: Duration, + pub meta: Option, +} + +pub const REFUSAL_FALLBACK_MODEL_META_KEY: &str = "refusal_fallback_model"; + +pub fn meta_with_refusal_fallback(model_name: &str) -> acp::Meta { + acp::Meta::from_iter([(REFUSAL_FALLBACK_MODEL_META_KEY.into(), model_name.into())]) +} + +pub fn refusal_fallback_model_from_meta(meta: &Option) -> Option { + meta.as_ref() + .and_then(|m| m.get(REFUSAL_FALLBACK_MODEL_META_KEY)) + .and_then(|v| v.as_str()) + .map(|s| SharedString::from(s.to_owned())) } struct RunningTurn { diff --git a/crates/agent/src/agent.rs b/crates/agent/src/agent.rs index e5ba57e90fd..c02711d5dd9 100644 --- a/crates/agent/src/agent.rs +++ b/crates/agent/src/agent.rs @@ -289,6 +289,10 @@ impl LanguageModels { self.refresh_models_rx.clone() } + pub fn notify_model_selection_changed(&mut self) { + self.refresh_models_tx.send(()).ok(); + } + pub fn model_from_id(&self, model_id: &AgentModelId) -> Option> { self.models.get(model_id).cloned() } @@ -1637,6 +1641,7 @@ impl NativeAgent { NativeAgentConnection::handle_thread_events( events, acp_thread.downgrade(), + None, cx, ) }) @@ -1854,10 +1859,12 @@ impl NativeAgent { } })?; + let connection = this.upgrade().map(NativeAgentConnection); cx.update(|cx| { NativeAgentConnection::handle_thread_events( response_stream, acp_thread.downgrade(), + connection, cx, ) }) @@ -1887,10 +1894,12 @@ impl NativeAgent { acp_thread.update_token_usage(None, cx); }); + let connection = this.upgrade().map(NativeAgentConnection); cx.update(|cx| { NativeAgentConnection::handle_thread_events( response_stream, acp_thread.downgrade(), + connection, cx, ) }) @@ -1989,10 +1998,12 @@ impl NativeAgent { let response_stream = thread.update(cx, |thread, cx| thread.send_existing(cx))?; + let connection = this.upgrade().map(NativeAgentConnection); cx.update(|cx| { NativeAgentConnection::handle_thread_events( response_stream, acp_thread.downgrade(), + connection, cx, ) }) @@ -2084,12 +2095,18 @@ impl NativeAgentConnection { Ok(stream) => stream, Err(err) => return Task::ready(Err(err)), }; - Self::handle_thread_events(response_stream, acp_thread.downgrade(), cx) + Self::handle_thread_events( + response_stream, + acp_thread.downgrade(), + Some(self.clone()), + cx, + ) } fn handle_thread_events( mut events: mpsc::UnboundedReceiver>, acp_thread: WeakEntity, + connection: Option, cx: &App, ) -> Task> { cx.spawn(async move |cx| { @@ -2163,6 +2180,17 @@ impl NativeAgentConnection { })?; } ThreadEvent::Retry(status) => { + if acp_thread::refusal_fallback_model_from_meta(&status.meta) + .is_some() + { + if let Some(connection) = &connection { + cx.update(|cx| { + connection.0.update(cx, |agent, _| { + agent.models.notify_model_selection_changed(); + }); + }); + } + } acp_thread.update(cx, |thread, cx| { thread.update_retry_status(status, cx) })?; diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 77fb60f2954..11c78737e85 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -1433,6 +1433,13 @@ impl Thread { stream: &ThreadEventStream, cx: &mut Context, ) { + // A tool call left only with the canceled sentinel produced nothing useful + // (the sentinel is model-facing only, and is inserted exactly when a tool + // had no real result). Don't replay it into the UI at all. + if tool_result.is_some_and(Self::is_canceled_tool_result) { + return; + } + let output = tool_result .as_ref() .and_then(|result| result.output.clone()); @@ -1530,6 +1537,18 @@ impl Thread { ); } + /// A canceled tool result carries only the model-facing `TOOL_CANCELED_MESSAGE` + /// sentinel (inserted exactly when a tool had no real result). It's never + /// meaningful to the user, so we detect it to skip replaying the tool call. + fn is_canceled_tool_result(tool_result: &LanguageModelToolResult) -> bool { + tool_result.is_error + && matches!( + tool_result.content.as_slice(), + [LanguageModelToolResultContent::Text(text)] + if text.as_ref() == TOOL_CANCELED_MESSAGE + ) + } + fn tool_result_content_for_replay( tool_result: &LanguageModelToolResult, ) -> Option> { @@ -2385,6 +2404,8 @@ impl Thread { ) -> Result<()> { let mut attempt = 0; let mut intent = CompletionIntent::UserPrompt; + // Set when a refusal fallback occurs so subsequent iterations use the fallback model. + let mut refusal_fallback_model: Option> = None; loop { if cx.update(|cx| cx.has_flag::()) { match Self::perform_compaction_if_needed( @@ -2424,10 +2445,11 @@ impl Thread { // Re-read the model and refresh tools on each iteration so that // mid-turn changes (e.g. the user switches model, toggles tools, // or changes profile) take effect between tool-call rounds. + // If a refusal fallback is active, use that model instead. let (model, request) = this.update(cx, |this, cx| { - let model = this - .model + let model = refusal_fallback_model .clone() + .or_else(|| this.model.clone()) .ok_or_else(|| anyhow!(NoModelConfiguredError))?; this.refresh_turn_tools(cx); let request = this.build_completion_request(intent, cx)?; @@ -2457,6 +2479,7 @@ impl Thread { FuturesUnordered::new(); let mut early_tool_results: Vec = Vec::new(); let mut cancelled = false; + let mut had_refusal = false; loop { // Race between getting the first event, tool completion, and cancellation. let first_event = futures::select! { @@ -2538,6 +2561,14 @@ impl Thread { tool_results.extend(batch_result.0); if let Some(err) = batch_result.1 { + let is_refusal = err + .downcast_ref::() + .is_some_and(|e| matches!(e, CompletionError::Refusal)); + if is_refusal { + log::info!("Model refused request; checking for fallback model"); + had_refusal = true; + break; + } error = Some(err.downcast()?); break; } @@ -2563,6 +2594,59 @@ impl Thread { } })?; + if had_refusal { + let maybe_fallback = this.update(cx, |this, cx| -> Option> { + let current_model = refusal_fallback_model.as_ref().or(this.model.as_ref())?; + let fallback_id = match current_model.refusal_fallback_model_id() { + Some(id) => id, + None => { + log::info!( + "Refusal fallback: no fallback configured for model {} (provider {})", + current_model.id().0, + current_model.provider_id() + ); + return None; + } + }; + let provider_id = current_model.provider_id(); + let found = LanguageModelRegistry::global(cx) + .read(cx) + .available_models(cx) + .find(|m| { + m.provider_id() == provider_id && m.id().0.as_ref() == fallback_id + }); + if found.is_none() { + log::info!( + "Refusal fallback: fallback model {}/{} not found in available models", + provider_id, + fallback_id + ); + } + found + })?; + + if let Some(fallback) = maybe_fallback { + log::info!("Refusal fallback: retrying with {}", fallback.id().0); + let fallback_name = fallback.name().0.clone(); + this.update(cx, |this, cx| { + this.pending_message = None; + this.set_model(fallback.clone(), cx); + })?; + event_stream.send_retry(acp_thread::RetryStatus { + last_error: "Safety filter triggered".into(), + attempt: 1, + max_attempts: 1, + started_at: Instant::now(), + duration: Duration::MAX, + meta: Some(acp_thread::meta_with_refusal_fallback(&fallback_name)), + }); + refusal_fallback_model = Some(fallback); + continue; + } + log::info!("Request refused with no fallback model available"); + return Err(CompletionError::Refusal.into()); + } + let end_turn = tool_results.is_empty() && early_tool_results.is_empty(); for tool_result in early_tool_results { @@ -2862,6 +2946,7 @@ impl Thread { max_attempts: max_attempts as usize, started_at: Instant::now(), duration: delay, + meta: None, }) } @@ -4012,6 +4097,9 @@ impl Thread { // Retrying won't help for Payment Required errors. None } + // Retrying won't help until the user consents to data retention + // or switches models. + DataRetentionConsentRequired { .. } => None, // Conservatively assume that any other errors are non-retryable HttpResponseError { .. } | Other(..) => Some(RetryStrategy::Fixed { delay: BASE_RETRY_DELAY, diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index e1bf3ffe280..925b813cd0a 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -123,6 +123,7 @@ enum ThreadFeedback { #[derive(Debug)] pub(crate) enum ThreadError { PaymentRequired, + DataRetentionConsentRequired, Refusal, AuthenticationRequired(SharedString), RateLimitExceeded { @@ -196,6 +197,7 @@ impl From for ThreadError { provider: provider.to_string().into(), }, UpstreamProviderError { .. } => Self::RequestFailed, + DataRetentionConsentRequired { .. } => Self::DataRetentionConsentRequired, BadRequestFormat { provider, .. } | HttpResponseError { provider, .. } | ApiEndpointNotFound { provider } => Self::ApiError { @@ -3445,6 +3447,21 @@ pub(crate) mod tests { use super::*; + #[test] + fn test_data_retention_error_maps_from_provider_error() { + // The agent wraps the provider error in a fresh `anyhow::Error`, so + // the mapping must downcast to `LanguageModelCompletionError` rather + // than matching on the anyhow error directly. + let provider_error = LanguageModelCompletionError::DataRetentionConsentRequired { + model_name: "Claude Fable 5".to_string(), + }; + let error = ThreadError::from(anyhow!(provider_error)); + assert!( + matches!(error, ThreadError::DataRetentionConsentRequired), + "expected ThreadError::DataRetentionConsentRequired, got: {error:?}" + ); + } + #[gpui::test] async fn test_drop(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 8603b3552c7..12cadfc859c 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -23,10 +23,10 @@ use gpui::Stateful; use gpui::TaskExt; use heapless::Vec as ArrayVec; use language_model::{ - FastModeConfirmation, LanguageModelEffortLevel, LanguageModelId, LanguageModelProviderId, - LanguageModelRegistry, Speed, + FastModeConfirmation, LanguageModel, LanguageModelEffortLevel, LanguageModelId, + LanguageModelProviderId, LanguageModelRegistry, Speed, }; -use settings::update_settings_file; +use settings::{update_settings_file, update_settings_file_with_completion}; use ui::{ ButtonLike, CalloutBorderPosition, SpinnerLabel, SpinnerVariant, SplitButton, SplitButtonStyle, Tab, @@ -36,6 +36,9 @@ use workspace::{OpenOptions, SERIALIZATION_THROTTLE_TIME}; use super::*; +// TODO: Replace with the final link explaining Anthropic's data retention policy. +const DATA_RETENTION_LEARN_MORE_URL: &str = "#link-tbd"; + #[derive(Default)] struct ThreadFeedbackState { feedback: Option, @@ -1784,6 +1787,13 @@ impl ThreadView { ); ("refusal", None, message.into()) } + ThreadError::DataRetentionConsentRequired => { + let message = format!( + "{} is not available with Zero Data Retention.", + self.current_model_name(cx) + ); + ("data_retention_consent_required", None, message.into()) + } ThreadError::AuthenticationRequired(message) => { ("authentication_required", None, message.clone()) } @@ -2691,9 +2701,28 @@ impl ThreadView { } } - pub fn render_thread_retry_status_callout(&self) -> Option { + pub fn render_thread_retry_status_callout(&self, cx: &mut Context) -> Option { let state = self.thread_retry_status.as_ref()?; + if let Some(fallback_model) = acp_thread::refusal_fallback_model_from_meta(&state.meta) { + return Some( + Callout::new() + .icon(IconName::Warning) + .severity(Severity::Warning) + .title(state.last_error.clone()) + .description(format!("Retrying with {fallback_model}")) + .dismiss_action( + IconButton::new("dismiss-refusal-fallback", IconName::Close) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Dismiss")) + .on_click(cx.listener(|this, _, _, cx| { + this.thread_retry_status = None; + cx.notify(); + })), + ), + ); + } + let next_attempt_in = state .duration .saturating_sub(Instant::now().saturating_duration_since(state.started_at)); @@ -5630,6 +5659,28 @@ impl ThreadView { } } AgentThreadEntry::ToolCall(tool_call) => { + // A canceled tool call that produced visible output is still worth + // showing, but one that was canceled before producing anything just + // renders as a useless "Canceled" card — hide those entirely. + if matches!(tool_call.status, ToolCallStatus::Canceled) { + let has_visible_content = + tool_call.content.iter().any(|content| match content { + ToolCallContent::ContentBlock(block) => match block { + ContentBlock::Empty => false, + ContentBlock::Markdown { markdown } => { + !markdown.read(cx).source().trim().is_empty() + } + ContentBlock::ResourceLink { .. } | ContentBlock::Image { .. } => { + true + } + }, + ToolCallContent::Diff(_) | ToolCallContent::Terminal(_) => true, + }); + if !has_visible_content { + return Empty.into_any(); + } + } + let tool_call = self.render_any_tool_call( self.thread.read(cx).session_id(), entry_ix, @@ -9382,6 +9433,9 @@ impl ThreadView { self.render_any_thread_error(message.clone(), window, cx) } ThreadError::Refusal => self.render_refusal_error(cx), + ThreadError::DataRetentionConsentRequired => { + self.render_data_retention_consent_error(cx) + } ThreadError::AuthenticationRequired(error) => { self.render_authentication_required_error(error.clone(), cx) } @@ -10153,6 +10207,118 @@ impl ThreadView { ) } + /// Returns the model to offer as a downgrade target when the current model + /// requires data retention consent (e.g. Opus 4.8 for Fable). + fn data_retention_fallback_model(&self, cx: &App) -> Option> { + let thread = self.as_native_thread(cx)?; + let model = thread.read(cx).model()?.clone(); + let fallback_id = model.refusal_fallback_model_id()?; + LanguageModelRegistry::read_global(cx) + .available_models(cx) + .find(|fallback| { + fallback.provider_id() == model.provider_id() + && fallback.id().0.as_ref() == fallback_id + }) + } + + fn render_data_retention_consent_error(&self, cx: &mut Context) -> Callout { + let fallback_model = self.data_retention_fallback_model(cx); + + Callout::new() + .severity(Severity::Warning) + .icon(IconName::Warning) + .title(format!( + "Note: {} cannot be offered with Zero Data Retention.", + self.current_model_name(cx) + )) + .description_slot( + h_flex() + .gap_1() + .child( + Label::new("Anthropic will retain inference logs.") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child( + Button::new("data-retention-learn-more", "Learn More") + .label_size(LabelSize::Small) + .on_click(|_, _, cx| { + cx.open_url(DATA_RETENTION_LEARN_MORE_URL); + }), + ), + ) + .actions_slot( + h_flex() + .gap_0p5() + .when_some(fallback_model, |this, fallback| { + this.child( + Button::new( + "switch-data-retention-fallback", + format!("Switch to {}", fallback.name().0), + ) + .label_size(LabelSize::Small) + .on_click(cx.listener(|this, _, _, cx| { + this.switch_to_data_retention_fallback_and_resend(cx); + })), + ) + }) + .child( + Button::new("accept-data-retention", "Accept") + .label_size(LabelSize::Small) + .style(ButtonStyle::Tinted(TintColor::Warning)) + .on_click(cx.listener(|this, _, _, cx| { + this.accept_data_retention_and_resend(cx); + })), + ), + ) + .dismiss_action(self.dismiss_error_button(cx)) + } + + fn accept_data_retention_and_resend(&mut self, cx: &mut Context) { + let fs = self.thread.read(cx).project().read(cx).fs().clone(); + // Resume the failed turn only once the in-memory settings reflect + // consent, otherwise the resent request would be rejected again. + let completion = update_settings_file_with_completion(fs, cx, |settings, _| { + settings + .telemetry + .get_or_insert_default() + .anthropic_retention = Some(true); + }); + cx.spawn(async move |this, cx| { + completion.await??; + this.update(cx, |this, cx| this.retry_generation(cx))?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + + fn switch_to_data_retention_fallback_and_resend(&mut self, cx: &mut Context) { + let Some(fallback) = self.data_retention_fallback_model(cx) else { + return; + }; + let model_id = acp_thread::AgentModelId::new(format!( + "{}/{}", + fallback.provider_id().0, + fallback.id().0 + )); + let session_id = self.thread.read(cx).session_id().clone(); + let Some(selector) = self + .thread + .read(cx) + .connection() + .model_selector(&session_id) + else { + return; + }; + let select = selector.select_model(model_id, cx); + cx.spawn(async move |this, cx| { + select.await?; + this.update(cx, |this, cx| this.retry_generation(cx))?; + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + fn open_permission_dropdown( &mut self, _: &crate::OpenPermissionDropdown, @@ -10512,7 +10678,7 @@ impl Render for ThreadView { this.child(self.render_codex_windows_warning(cx)) }) .children(self.render_skill_loading_issues(cx)) - .children(self.render_thread_retry_status_callout()) + .children(self.render_thread_retry_status_callout(cx)) .children(self.render_thread_error(window, cx)) .when_some( match has_messages { diff --git a/crates/anthropic/src/anthropic.rs b/crates/anthropic/src/anthropic.rs index b8d4253d859..90b1a841809 100644 --- a/crates/anthropic/src/anthropic.rs +++ b/crates/anthropic/src/anthropic.rs @@ -20,6 +20,9 @@ pub mod completion; pub const ANTHROPIC_API_URL: &str = "https://api.anthropic.com"; const FAST_MODE_BETA_HEADER: &str = "fast-mode-2026-02-01"; +pub const FABLE_MODEL_ID_PREFIX: &str = "claude-fable-5"; +pub const FABLE_FALLBACK_MODEL_ID: &str = "claude-opus-4-8"; + #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] pub enum AnthropicModelMode { diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 4378ca37fe2..1d6524ec7f4 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -540,13 +540,16 @@ impl Drop for PendingEntitySubscription { pub struct TelemetrySettings { pub diagnostics: bool, pub metrics: bool, + pub anthropic_retention: bool, } impl settings::Settings for TelemetrySettings { fn from_settings(content: &SettingsContent) -> Self { + let telemetry = content.telemetry.as_ref().unwrap(); Self { - diagnostics: content.telemetry.as_ref().unwrap().diagnostics.unwrap(), - metrics: content.telemetry.as_ref().unwrap().metrics.unwrap(), + diagnostics: telemetry.diagnostics.unwrap(), + metrics: telemetry.metrics.unwrap(), + anthropic_retention: telemetry.anthropic_retention.unwrap(), } } } diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs index 7dc237a65dd..5cf1a6ea087 100644 --- a/crates/language_model/src/language_model.rs +++ b/crates/language_model/src/language_model.rs @@ -60,6 +60,18 @@ pub trait LanguageModel: Send + Sync { false } + /// Whether requests to this model require the user to consent to the + /// upstream provider retaining inference logs (i.e. the model cannot be + /// offered with Zero Data Retention). + fn requires_data_retention(&self) -> bool { + false + } + + /// When this model refuses a request, the model ID to fall back to (same provider). + fn refusal_fallback_model_id(&self) -> Option<&'static str> { + None + } + fn telemetry_id(&self) -> String; fn api_key(&self, _cx: &App) -> Option { diff --git a/crates/language_model_core/src/language_model_core.rs b/crates/language_model_core/src/language_model_core.rs index c2b7011ab2b..3dd8330f71c 100644 --- a/crates/language_model_core/src/language_model_core.rs +++ b/crates/language_model_core/src/language_model_core.rs @@ -88,6 +88,14 @@ impl LanguageModelCompletionEvent { pub enum LanguageModelCompletionError { #[error("prompt too large for context window")] PromptTooLarge { tokens: Option }, + /// The model requires the user to consent to the upstream provider + /// retaining inference logs (see `LanguageModel::requires_data_retention`) + /// and that consent has not been given. + #[error( + "{model_name} cannot be offered with Zero Data Retention. \ + Anthropic will retain inference logs." + )] + DataRetentionConsentRequired { model_name: String }, #[error("missing {provider} API key")] NoApiKey { provider: LanguageModelProviderName }, #[error("{provider}'s API rate limit exceeded")] diff --git a/crates/language_models/src/provider/anthropic.rs b/crates/language_models/src/provider/anthropic.rs index cb7f8b7aa11..6ac0dd18f8b 100644 --- a/crates/language_models/src/provider/anthropic.rs +++ b/crates/language_models/src/provider/anthropic.rs @@ -463,6 +463,14 @@ impl LanguageModel for AnthropicModel { self.model.supports_speed } + fn refusal_fallback_model_id(&self) -> Option<&'static str> { + if self.model.id.starts_with(anthropic::FABLE_MODEL_ID_PREFIX) { + Some(anthropic::FABLE_FALLBACK_MODEL_ID) + } else { + None + } + } + fn supported_effort_levels(&self) -> Vec { self.model .supported_effort_levels diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs index 435f06cb10e..92c21062367 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -1,6 +1,8 @@ use ai_onboarding::YoungAccountBanner; use anyhow::{Result, anyhow}; -use client::{Client, RefreshLlmTokenListener, UserStore, global_llm_token, zed_urls}; +use client::{ + Client, RefreshLlmTokenListener, TelemetrySettings, UserStore, global_llm_token, zed_urls, +}; use cloud_api_client::LlmApiToken; use cloud_api_types::OrganizationId; use cloud_api_types::Plan; @@ -74,6 +76,14 @@ impl CloudLlmTokenProvider for ClientTokenProvider { .await }) } + + fn has_data_retention_consent(&self, cx: &impl AppContext) -> bool { + cx.read_global(|settings_store: &SettingsStore, _| { + settings_store + .get::(None) + .anthropic_retention + }) + } } #[derive(Default, Clone, Debug, PartialEq)] diff --git a/crates/language_models_cloud/src/language_models_cloud.rs b/crates/language_models_cloud/src/language_models_cloud.rs index 940eb51dc37..c129042dec6 100644 --- a/crates/language_models_cloud/src/language_models_cloud.rs +++ b/crates/language_models_cloud/src/language_models_cloud.rs @@ -55,6 +55,11 @@ pub trait CloudLlmTokenProvider: Send + Sync { fn auth_context(&self, cx: &impl AppContext) -> Self::AuthContext; fn cached_token(&self, auth_context: Self::AuthContext) -> BoxFuture<'static, Result>; fn refresh_token(&self, auth_context: Self::AuthContext) -> BoxFuture<'static, Result>; + + /// Whether the user has consented to upstream providers retaining + /// inference logs for models that require it (see + /// [`LanguageModel::requires_data_retention`]). + fn has_data_retention_consent(&self, cx: &impl AppContext) -> bool; } /// Sends an authenticated request to the Zed LLM service, retrying once with @@ -298,6 +303,27 @@ impl LanguageModel for CloudLanguageModel bool { + // Anthropic cannot offer Fable models with Zero Data Retention + self.id + .0 + .as_ref() + .starts_with(anthropic::FABLE_MODEL_ID_PREFIX) + } + + fn refusal_fallback_model_id(&self) -> Option<&'static str> { + if self + .id + .0 + .as_ref() + .starts_with(anthropic::FABLE_MODEL_ID_PREFIX) + { + Some(anthropic::FABLE_FALLBACK_MODEL_ID) + } else { + None + } + } + fn supports_tools(&self) -> bool { self.model.supports_tools } @@ -379,6 +405,14 @@ impl LanguageModel for CloudLanguageModel, > { + if self.requires_data_retention() && !self.token_provider.has_data_retention_consent(cx) { + let model_name = self.model.display_name.clone(); + return async move { + Err(LanguageModelCompletionError::DataRetentionConsentRequired { model_name }) + } + .boxed(); + } + let thread_id = request.thread_id.clone(); let prompt_id = request.prompt_id.clone(); let app_version = self.app_version.clone(); diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index e4053bd2b75..c2a9ec74c26 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -862,6 +862,7 @@ impl VsCodeSettings { Some(TelemetrySettingsContent { metrics: Some(metrics), diagnostics: Some(diagnostics), + anthropic_retention: None, }) }) } diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs index adb6fa884ec..ec1ab6f08bd 100644 --- a/crates/settings_content/src/settings_content.rs +++ b/crates/settings_content/src/settings_content.rs @@ -521,6 +521,11 @@ pub struct TelemetrySettingsContent { /// /// Default: true pub metrics: Option, + /// Allow sending requests to Anthropic models that cannot be offered with + /// Zero Data Retention. + /// + /// Default: false + pub anthropic_retention: Option, } impl Default for TelemetrySettingsContent { @@ -528,6 +533,7 @@ impl Default for TelemetrySettingsContent { Self { diagnostics: Some(true), metrics: Some(true), + anthropic_retention: Some(false), } } } diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index d71255cd9d1..f54c841e0b9 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -365,7 +365,7 @@ fn general_page(cx: &App) -> SettingsPage { ] } - fn privacy_section() -> [SettingsPageItem; 3] { + fn privacy_section() -> [SettingsPageItem; 4] { [ SettingsPageItem::SectionHeader("Privacy"), SettingsPageItem::SettingItem(SettingItem { @@ -409,6 +409,28 @@ fn general_page(cx: &App) -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::SettingItem(SettingItem { + title: "Anthropic Data Retention", + description: "Allow sending requests to Anthropic models that cannot be offered with Zero Data Retention.", + field: Box::new(SettingField { + organization_override: None, + json_path: Some("telemetry.anthropic_retention"), + pick: |settings_content| { + settings_content + .telemetry + .as_ref() + .and_then(|telemetry| telemetry.anthropic_retention.as_ref()) + }, + write: |settings_content, value, _| { + settings_content + .telemetry + .get_or_insert_default() + .anthropic_retention = value; + }, + }), + metadata: None, + files: USER, + }), ] }