Add Claude Fable 5 to Anthropic BYOK (#58945)

<img width="325" height="201" alt="Screenshot 2026-06-09 at 1 38 32 PM"
src="https://github.com/user-attachments/assets/a6518073-1e17-41ff-a8fc-cb279fcd4436"
/>

Adds support for Anthropic's Claude Fable 5 model when using your own
Anthropic API key. Because Fable 5 cannot be offered under Zero Data
Retention (Anthropic retains inference logs for 30 days), this gates the
model behind an explicit data-retention consent: a new
telemetry.anthropic_retention setting (default off, surfaced in the
Privacy section of the settings UI), and a hard, non-retryable check in
the cloud completion path that raises a typed error when consent is
missing.

When Fable 5 declines a request, it transparently falls back to Claude
Opus 4.8 (matching Anthropic's server-side behavior), and the agent
panel shows a callout for the consent error with "Switch to Opus 4.8" /
"Accept" actions that resume the failed turn so the user's message
continues without retyping.

Closes AI-382

Release Notes:

- Add Claude Fable 5 to Anthropic BYOK

---------

Co-authored-by: Mikayla Maki <mikayla@zed.dev>
This commit is contained in:
Richard Feldman 2026-06-09 13:53:59 -04:00 committed by GitHub
parent 48511e0b9c
commit 8bd2ce7069
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 473 additions and 12 deletions

View file

@ -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.
//

View file

@ -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(&current) {
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<gpui::Image>, Option<gpui::Size<u32>>)> {
@ -1061,6 +1088,17 @@ impl ToolCallContent {
terminals: &HashMap<acp::TerminalId, Entity<Terminal>>,
cx: &mut App,
) -> Result<bool> {
// 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<acp::Meta>,
}
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<acp::Meta>) -> Option<SharedString> {
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 {

View file

@ -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<Arc<dyn LanguageModel>> {
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<Result<ThreadEvent>>,
acp_thread: WeakEntity<AcpThread>,
connection: Option<NativeAgentConnection>,
cx: &App,
) -> Task<Result<acp::PromptResponse>> {
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)
})?;

View file

@ -1433,6 +1433,13 @@ impl Thread {
stream: &ThreadEventStream,
cx: &mut Context<Self>,
) {
// 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<Vec<acp::ToolCallContent>> {
@ -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<Arc<dyn LanguageModel>> = None;
loop {
if cx.update(|cx| cx.has_flag::<HandoffFeatureFlag>()) {
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<LanguageModelToolResult> = 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::<CompletionError>()
.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<Arc<dyn LanguageModel>> {
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,

View file

@ -123,6 +123,7 @@ enum ThreadFeedback {
#[derive(Debug)]
pub(crate) enum ThreadError {
PaymentRequired,
DataRetentionConsentRequired,
Refusal,
AuthenticationRequired(SharedString),
RateLimitExceeded {
@ -196,6 +197,7 @@ impl From<anyhow::Error> 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);

View file

@ -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<ThreadFeedback>,
@ -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<Callout> {
pub fn render_thread_retry_status_callout(&self, cx: &mut Context<Self>) -> Option<Callout> {
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<Arc<dyn LanguageModel>> {
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<Self>) -> 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<Self>) {
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<Self>) {
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 {

View file

@ -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 {

View file

@ -540,13 +540,16 @@ impl<T: 'static> Drop for PendingEntitySubscription<T> {
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(),
}
}
}

View file

@ -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<String> {

View file

@ -88,6 +88,14 @@ impl LanguageModelCompletionEvent {
pub enum LanguageModelCompletionError {
#[error("prompt too large for context window")]
PromptTooLarge { tokens: Option<u64> },
/// 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")]

View file

@ -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<language_model::LanguageModelEffortLevel> {
self.model
.supported_effort_levels

View file

@ -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::<TelemetrySettings>(None)
.anthropic_retention
})
}
}
#[derive(Default, Clone, Debug, PartialEq)]

View file

@ -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<String>>;
fn refresh_token(&self, auth_context: Self::AuthContext) -> BoxFuture<'static, Result<String>>;
/// 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<TP: CloudLlmTokenProvider + 'static> LanguageModel for CloudLanguageModel<T
self.model.is_latest
}
fn requires_data_retention(&self) -> 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<TP: CloudLlmTokenProvider + 'static> LanguageModel for CloudLanguageModel<T
LanguageModelCompletionError,
>,
> {
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();

View file

@ -862,6 +862,7 @@ impl VsCodeSettings {
Some(TelemetrySettingsContent {
metrics: Some(metrics),
diagnostics: Some(diagnostics),
anthropic_retention: None,
})
})
}

View file

@ -521,6 +521,11 @@ pub struct TelemetrySettingsContent {
///
/// Default: true
pub metrics: Option<bool>,
/// Allow sending requests to Anthropic models that cannot be offered with
/// Zero Data Retention.
///
/// Default: false
pub anthropic_retention: Option<bool>,
}
impl Default for TelemetrySettingsContent {
@ -528,6 +533,7 @@ impl Default for TelemetrySettingsContent {
Self {
diagnostics: Some(true),
metrics: Some(true),
anthropic_retention: Some(false),
}
}
}

View file

@ -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,
}),
]
}