mirror of
https://github.com/zed-industries/zed.git
synced 2026-07-09 16:00:35 +00:00
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:
parent
48511e0b9c
commit
8bd2ce7069
16 changed files with 473 additions and 12 deletions
|
|
@ -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.
|
||||
//
|
||||
|
|
|
|||
|
|
@ -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<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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})?;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -862,6 +862,7 @@ impl VsCodeSettings {
|
|||
Some(TelemetrySettingsContent {
|
||||
metrics: Some(metrics),
|
||||
diagnostics: Some(diagnostics),
|
||||
anthropic_retention: None,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue