diff --git a/crates/anthropic/src/anthropic.rs b/crates/anthropic/src/anthropic.rs index 269edbea7e2..c7afbbfb6b0 100644 --- a/crates/anthropic/src/anthropic.rs +++ b/crates/anthropic/src/anthropic.rs @@ -2,13 +2,13 @@ use std::io; use std::str::FromStr; use std::time::Duration; -use anyhow::{Context as _, Result, anyhow}; +use anyhow::{Context as _, Result}; use chrono::{DateTime, Utc}; use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::BoxStream}; use http_client::http::{self, HeaderMap, HeaderValue}; use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest, StatusCode}; use serde::{Deserialize, Serialize}; -use strum::{EnumIter, EnumString}; +use strum::EnumString; use thiserror::Error; pub mod batches; @@ -16,14 +16,6 @@ pub mod completion; pub const ANTHROPIC_API_URL: &str = "https://api.anthropic.com"; -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] -pub struct AnthropicModelCacheConfiguration { - pub min_total_token: u64, - pub should_speculate: bool, - pub max_cache_anchors: usize, -} - #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] pub enum AnthropicModelMode { @@ -35,343 +27,152 @@ pub enum AnthropicModelMode { AdaptiveThinking, } -#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] -#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, EnumIter)] -pub enum Model { - #[serde( - rename = "claude-opus-4", - alias = "claude-opus-4-latest", - alias = "claude-opus-4-thinking", - alias = "claude-opus-4-thinking-latest" - )] - ClaudeOpus4, - #[serde( - rename = "claude-opus-4-1", - alias = "claude-opus-4-1-latest", - alias = "claude-opus-4-1-thinking", - alias = "claude-opus-4-1-thinking-latest" - )] - ClaudeOpus4_1, - #[serde( - rename = "claude-opus-4-5", - alias = "claude-opus-4-5-latest", - alias = "claude-opus-4-5-thinking", - alias = "claude-opus-4-5-thinking-latest" - )] - ClaudeOpus4_5, - #[serde( - rename = "claude-opus-4-6", - alias = "claude-opus-4-6-latest", - alias = "claude-opus-4-6-1m-context", - alias = "claude-opus-4-6-1m-context-latest", - alias = "claude-opus-4-6-thinking", - alias = "claude-opus-4-6-thinking-latest", - alias = "claude-opus-4-6-1m-context-thinking", - alias = "claude-opus-4-6-1m-context-thinking-latest" - )] - ClaudeOpus4_6, - #[serde( - rename = "claude-opus-4-7", - alias = "claude-opus-4-7-latest", - alias = "claude-opus-4-7-1m-context", - alias = "claude-opus-4-7-1m-context-latest", - alias = "claude-opus-4-7-thinking", - alias = "claude-opus-4-7-thinking-latest", - alias = "claude-opus-4-7-1m-context-thinking", - alias = "claude-opus-4-7-1m-context-thinking-latest" - )] - ClaudeOpus4_7, - #[serde( - rename = "claude-sonnet-4", - alias = "claude-sonnet-4-latest", - alias = "claude-sonnet-4-thinking", - alias = "claude-sonnet-4-thinking-latest" - )] - ClaudeSonnet4, - #[serde( - rename = "claude-sonnet-4-5", - alias = "claude-sonnet-4-5-latest", - alias = "claude-sonnet-4-5-thinking", - alias = "claude-sonnet-4-5-thinking-latest" - )] - ClaudeSonnet4_5, - #[default] - #[serde( - rename = "claude-sonnet-4-6", - alias = "claude-sonnet-4-6-latest", - alias = "claude-sonnet-4-6-1m-context", - alias = "claude-sonnet-4-6-1m-context-latest", - alias = "claude-sonnet-4-6-thinking", - alias = "claude-sonnet-4-6-thinking-latest", - alias = "claude-sonnet-4-6-1m-context-thinking", - alias = "claude-sonnet-4-6-1m-context-thinking-latest" - )] - ClaudeSonnet4_6, - #[serde( - rename = "claude-haiku-4-5", - alias = "claude-haiku-4-5-latest", - alias = "claude-haiku-4-5-thinking", - alias = "claude-haiku-4-5-thinking-latest" - )] - ClaudeHaiku4_5, - #[serde(rename = "claude-3-haiku", alias = "claude-3-haiku-latest")] - Claude3Haiku, - #[serde(rename = "custom")] - Custom { - name: String, - max_tokens: u64, - /// The name displayed in the UI, such as in the agent panel model dropdown menu. - display_name: Option, - /// Override this model with a different Anthropic model for tool calls. - tool_override: Option, - /// Indicates whether this custom model supports caching. - cache_configuration: Option, - max_output_tokens: Option, - default_temperature: Option, - #[serde(default)] - extra_beta_headers: Vec, - #[serde(default)] - mode: AnthropicModelMode, - }, +/// Capabilities reported by the Anthropic models endpoint for a given model. +#[derive(Clone, Debug, Default, Deserialize)] +pub struct ModelCapabilities { + #[serde(default)] + pub thinking: Option, + #[serde(default)] + pub image_input: Option, + #[serde(default)] + pub effort: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct SupportedCapability { + #[serde(default)] + pub supported: bool, +} + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct ThinkingCapability { + #[serde(default)] + pub supported: bool, + #[serde(default)] + pub types: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct ThinkingTypes { + #[serde(default)] + pub adaptive: SupportedCapability, + #[serde(default)] + pub enabled: SupportedCapability, +} + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct EffortCapability { + #[serde(default)] + pub supported: bool, + #[serde(default)] + pub low: Option, + #[serde(default)] + pub medium: Option, + #[serde(default)] + pub high: Option, + #[serde(default)] + pub max: Option, + #[serde(default)] + pub xhigh: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Model { + pub id: String, + pub display_name: String, + pub max_input_tokens: u64, + pub max_output_tokens: u64, + pub default_temperature: f32, + pub mode: AnthropicModelMode, + pub supports_thinking: bool, + pub supports_adaptive_thinking: bool, + pub supports_images: bool, + pub supports_speed: bool, + pub supported_effort_levels: Vec, + /// A model id to substitute when invoking tools, used for models that + /// don't support tool calling natively. + pub tool_override: Option, + /// Extra `Anthropic-Beta` header values to send with each request. + pub extra_beta_headers: Vec, } impl Model { - pub fn default_fast() -> Self { - Self::ClaudeHaiku4_5 - } + /// Construct a `Model` from an entry returned by the `/v1/models` listing endpoint. + pub fn from_listed(entry: ListModelEntry) -> Self { + let supports_thinking = entry + .capabilities + .as_ref() + .and_then(|t| t.thinking.as_ref()) + .map(|t| t.supported) + .unwrap_or(false); + let supports_adaptive_thinking = entry + .capabilities + .as_ref() + .and_then(|t| t.thinking.as_ref()) + .and_then(|t| t.types.as_ref()) + .map(|types| types.adaptive.supported) + .unwrap_or(false); + let supports_images = entry + .capabilities + .as_ref() + .and_then(|c| c.image_input.as_ref()) + .map(|c| c.supported) + .unwrap_or(false); - pub fn from_id(id: &str) -> Result { - if id.starts_with("claude-opus-4-7") { - return Ok(Self::ClaudeOpus4_7); - } - - if id.starts_with("claude-opus-4-6") { - return Ok(Self::ClaudeOpus4_6); - } - - if id.starts_with("claude-opus-4-5") { - return Ok(Self::ClaudeOpus4_5); - } - - if id.starts_with("claude-opus-4-1") { - return Ok(Self::ClaudeOpus4_1); - } - - if id.starts_with("claude-opus-4") { - return Ok(Self::ClaudeOpus4); - } - - if id.starts_with("claude-sonnet-4-6") { - return Ok(Self::ClaudeSonnet4_6); - } - - if id.starts_with("claude-sonnet-4-5") { - return Ok(Self::ClaudeSonnet4_5); - } - - if id.starts_with("claude-sonnet-4") { - return Ok(Self::ClaudeSonnet4); - } - - if id.starts_with("claude-haiku-4-5") { - return Ok(Self::ClaudeHaiku4_5); - } - - if id.starts_with("claude-3-haiku") { - return Ok(Self::Claude3Haiku); - } - - Err(anyhow!("invalid model ID: {id}")) - } - - pub fn id(&self) -> &str { - match self { - Self::ClaudeOpus4 => "claude-opus-4-latest", - Self::ClaudeOpus4_1 => "claude-opus-4-1-latest", - Self::ClaudeOpus4_5 => "claude-opus-4-5-latest", - Self::ClaudeOpus4_6 => "claude-opus-4-6-latest", - Self::ClaudeOpus4_7 => "claude-opus-4-7-latest", - Self::ClaudeSonnet4 => "claude-sonnet-4-latest", - Self::ClaudeSonnet4_5 => "claude-sonnet-4-5-latest", - Self::ClaudeSonnet4_6 => "claude-sonnet-4-6-latest", - Self::ClaudeHaiku4_5 => "claude-haiku-4-5-latest", - Self::Claude3Haiku => "claude-3-haiku-20240307", - Self::Custom { name, .. } => name, - } - } - - /// The id of the model that should be used for making API requests - pub fn request_id(&self) -> &str { - match self { - Self::ClaudeOpus4 => "claude-opus-4-20250514", - Self::ClaudeOpus4_1 => "claude-opus-4-1-20250805", - Self::ClaudeOpus4_5 => "claude-opus-4-5-20251101", - Self::ClaudeOpus4_6 => "claude-opus-4-6", - Self::ClaudeOpus4_7 => "claude-opus-4-7", - Self::ClaudeSonnet4 => "claude-sonnet-4-20250514", - Self::ClaudeSonnet4_5 => "claude-sonnet-4-5-20250929", - Self::ClaudeSonnet4_6 => "claude-sonnet-4-6", - Self::ClaudeHaiku4_5 => "claude-haiku-4-5-20251001", - Self::Claude3Haiku => "claude-3-haiku-20240307", - Self::Custom { name, .. } => name, - } - } - - pub fn display_name(&self) -> &str { - match self { - Self::ClaudeOpus4 => "Claude Opus 4", - Self::ClaudeOpus4_1 => "Claude Opus 4.1", - Self::ClaudeOpus4_5 => "Claude Opus 4.5", - Self::ClaudeOpus4_6 => "Claude Opus 4.6", - Self::ClaudeOpus4_7 => "Claude Opus 4.7", - Self::ClaudeSonnet4 => "Claude Sonnet 4", - Self::ClaudeSonnet4_5 => "Claude Sonnet 4.5", - Self::ClaudeSonnet4_6 => "Claude Sonnet 4.6", - Self::ClaudeHaiku4_5 => "Claude Haiku 4.5", - Self::Claude3Haiku => "Claude 3 Haiku", - Self::Custom { - name, display_name, .. - } => display_name.as_ref().unwrap_or(name), - } - } - - pub fn cache_configuration(&self) -> Option { - match self { - Self::ClaudeOpus4 - | Self::ClaudeOpus4_1 - | Self::ClaudeOpus4_5 - | Self::ClaudeOpus4_6 - | Self::ClaudeOpus4_7 - | Self::ClaudeSonnet4 - | Self::ClaudeSonnet4_5 - | Self::ClaudeSonnet4_6 - | Self::ClaudeHaiku4_5 - | Self::Claude3Haiku => Some(AnthropicModelCacheConfiguration { - min_total_token: 2_048, - should_speculate: true, - max_cache_anchors: 4, - }), - Self::Custom { - cache_configuration, - .. - } => cache_configuration.clone(), - } - } - - pub fn max_token_count(&self) -> u64 { - match self { - Self::ClaudeOpus4 - | Self::ClaudeOpus4_1 - | Self::ClaudeOpus4_5 - | Self::ClaudeSonnet4 - | Self::ClaudeSonnet4_5 - | Self::ClaudeHaiku4_5 - | Self::Claude3Haiku => 200_000, - Self::ClaudeOpus4_6 | Self::ClaudeOpus4_7 | Self::ClaudeSonnet4_6 => 1_000_000, - Self::Custom { max_tokens, .. } => *max_tokens, - } - } - - pub fn max_output_tokens(&self) -> u64 { - match self { - Self::ClaudeOpus4 | Self::ClaudeOpus4_1 => 32_000, - Self::ClaudeOpus4_5 - | Self::ClaudeSonnet4 - | Self::ClaudeSonnet4_5 - | Self::ClaudeSonnet4_6 - | Self::ClaudeHaiku4_5 => 64_000, - Self::ClaudeOpus4_6 | Self::ClaudeOpus4_7 => 128_000, - Self::Claude3Haiku => 4_096, - Self::Custom { - max_output_tokens, .. - } => max_output_tokens.unwrap_or(4_096), - } - } - - pub fn default_temperature(&self) -> f32 { - match self { - Self::ClaudeOpus4 - | Self::ClaudeOpus4_1 - | Self::ClaudeOpus4_5 - | Self::ClaudeOpus4_6 - | Self::ClaudeOpus4_7 - | Self::ClaudeSonnet4 - | Self::ClaudeSonnet4_5 - | Self::ClaudeSonnet4_6 - | Self::ClaudeHaiku4_5 - | Self::Claude3Haiku => 1.0, - Self::Custom { - default_temperature, - .. - } => default_temperature.unwrap_or(1.0), - } - } - - pub fn mode(&self) -> AnthropicModelMode { - match self { - Self::Custom { mode, .. } => mode.clone(), - _ if self.supports_adaptive_thinking() => AnthropicModelMode::AdaptiveThinking, - _ if self.supports_thinking() => AnthropicModelMode::Thinking { - budget_tokens: Some(4_096), - }, - _ => AnthropicModelMode::Default, - } - } - - pub fn supports_thinking(&self) -> bool { - match self { - Self::Custom { mode, .. } => { - matches!( - mode, - AnthropicModelMode::Thinking { .. } | AnthropicModelMode::AdaptiveThinking - ) + let mut supported_effort_levels = Vec::new(); + if let Some(effort) = entry.capabilities.as_ref().and_then(|e| e.effort.as_ref()) { + // The `xhigh` effort level reported by the API has no + // corresponding `Effort` variant in the request enum, so it is + // intentionally dropped here. + for (level, supported) in [ + (Effort::Low, effort.low.as_ref()), + (Effort::Medium, effort.medium.as_ref()), + (Effort::High, effort.high.as_ref()), + (Effort::XHigh, effort.xhigh.as_ref()), + (Effort::Max, effort.max.as_ref()), + ] { + if supported.map(|c| c.supported).unwrap_or(false) { + supported_effort_levels.push(level); + } } - _ => matches!( - self, - Self::ClaudeOpus4 - | Self::ClaudeOpus4_1 - | Self::ClaudeOpus4_5 - | Self::ClaudeOpus4_6 - | Self::ClaudeOpus4_7 - | Self::ClaudeSonnet4 - | Self::ClaudeSonnet4_5 - | Self::ClaudeSonnet4_6 - | Self::ClaudeHaiku4_5 - ), } - } - pub fn supports_speed(&self) -> bool { - matches!(self, Self::ClaudeOpus4_6 | Self::ClaudeSonnet4_6) - } + let mode = if supports_adaptive_thinking { + AnthropicModelMode::AdaptiveThinking + } else if supports_thinking { + AnthropicModelMode::Thinking { + budget_tokens: Some(4_096), + } + } else { + AnthropicModelMode::Default + }; - pub fn supports_adaptive_thinking(&self) -> bool { - match self { - Self::Custom { mode, .. } => matches!(mode, AnthropicModelMode::AdaptiveThinking), - _ => matches!( - self, - Self::ClaudeOpus4_6 | Self::ClaudeOpus4_7 | Self::ClaudeSonnet4_6 - ), + let supports_speed = entry.id == "claude-opus-4-6"; + + Self { + display_name: entry.display_name, + id: entry.id, + max_input_tokens: entry.max_input_tokens, + max_output_tokens: entry.max_tokens, + default_temperature: 1.0, + mode, + supports_thinking, + supports_adaptive_thinking, + supports_images, + supports_speed, + supported_effort_levels, + tool_override: None, + extra_beta_headers: Vec::new(), } } pub fn beta_headers(&self) -> Option { - let mut headers = vec![]; - - match self { - Self::Custom { - extra_beta_headers, .. - } => { - headers.extend( - extra_beta_headers - .iter() - .filter(|header| !header.trim().is_empty()) - .cloned(), - ); - } - _ => {} - } - + let headers: Vec<&str> = self + .extra_beta_headers + .iter() + .map(|h| h.trim()) + .filter(|h| !h.is_empty()) + .collect(); if headers.is_empty() { None } else { @@ -379,15 +180,11 @@ impl Model { } } - pub fn tool_model_id(&self) -> &str { - if let Self::Custom { - tool_override: Some(tool_override), - .. - } = self - { - tool_override + pub fn request_id(&self, has_tools: bool) -> &str { + if has_tools { + self.tool_override.as_deref().unwrap_or(&self.id) } else { - self.request_id() + &self.id } } } @@ -405,6 +202,73 @@ pub async fn stream_completion( .map(|output| output.0) } +/// A raw model entry returned by the Anthropic models listing endpoint. +#[derive(Clone, Debug, Deserialize)] +pub struct ListModelEntry { + pub id: String, + pub display_name: String, + pub max_input_tokens: u64, + pub max_tokens: u64, + #[serde(default)] + pub capabilities: Option, +} + +#[derive(Debug, Deserialize)] +struct ListModelsResponse { + data: Vec, +} + +/// Fetch the list of models available to the current API key. The returned +/// models are constructed by feeding each raw entry through +/// [`Model::from_listed`]. +/// +/// See https://docs.claude.com/en/api/models-list. +pub async fn list_models( + client: &dyn HttpClient, + api_url: &str, + api_key: &str, +) -> Result> { + let uri = format!("{api_url}/v1/models?limit=1000"); + + let request = HttpRequest::builder() + .method(Method::GET) + .uri(uri) + .header("Anthropic-Version", "2023-06-01") + .header("X-Api-Key", api_key.trim()) + .header("Accept", "application/json") + .body(AsyncBody::default()) + .context("failed to build Anthropic models list request")?; + + let mut response = client + .send(request) + .await + .context("failed to send Anthropic models list request")?; + + let mut body = String::new(); + response + .body_mut() + .read_to_string(&mut body) + .await + .context("failed to read Anthropic models list response")?; + + anyhow::ensure!( + response.status().is_success(), + "failed to list Anthropic models: {} {}", + response.status(), + body, + ); + + let parsed: ListModelsResponse = + serde_json::from_str(&body).context("failed to parse Anthropic models list response")?; + + let models = parsed + .data + .into_iter() + .map(Model::from_listed) + .collect::>(); + Ok(models) +} + /// Generate completion without streaming. pub async fn non_streaming_completion( client: &dyn HttpClient, @@ -780,13 +644,14 @@ pub enum AdaptiveThinkingDisplay { Summarized, } -#[derive(Debug, Clone, Copy, Serialize, Deserialize, EnumString)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, EnumString)] #[serde(rename_all = "snake_case")] #[strum(serialize_all = "snake_case")] pub enum Effort { Low, Medium, High, + XHigh, Max, } @@ -1095,63 +960,93 @@ impl From for language_model_core::LanguageModelCompletionError { } } -#[test] -fn custom_mode_thinking_is_preserved() { - let model = Model::Custom { - name: "my-custom-model".to_string(), - max_tokens: 8192, - display_name: None, - tool_override: None, - cache_configuration: None, - max_output_tokens: None, - default_temperature: None, - extra_beta_headers: vec![], - mode: AnthropicModelMode::Thinking { - budget_tokens: Some(2048), - }, - }; - assert_eq!( - model.mode(), - AnthropicModelMode::Thinking { - budget_tokens: Some(2048) +#[cfg(test)] +mod tests { + use super::*; + + fn listed_entry(id: &str, capabilities: ModelCapabilities) -> ListModelEntry { + ListModelEntry { + id: id.to_string(), + display_name: id.to_string(), + max_input_tokens: 200_000, + max_tokens: 64_000, + capabilities: Some(capabilities), } - ); - assert!(model.supports_thinking()); -} + } -#[test] -fn custom_mode_adaptive_is_preserved() { - let model = Model::Custom { - name: "my-custom-model".to_string(), - max_tokens: 8192, - display_name: None, - tool_override: None, - cache_configuration: None, - max_output_tokens: None, - default_temperature: None, - extra_beta_headers: vec![], - mode: AnthropicModelMode::AdaptiveThinking, - }; - assert_eq!(model.mode(), AnthropicModelMode::AdaptiveThinking); - assert!(model.supports_adaptive_thinking()); - assert!(model.supports_thinking()); -} + #[test] + fn from_listed_picks_adaptive_thinking_mode() { + let entry = listed_entry( + "claude-test-adaptive", + ModelCapabilities { + thinking: Some(ThinkingCapability { + supported: true, + types: Some(ThinkingTypes { + adaptive: SupportedCapability { supported: true }, + enabled: SupportedCapability { supported: true }, + }), + }), + ..Default::default() + }, + ); + let model = Model::from_listed(entry); + assert!(model.supports_thinking); + assert!(model.supports_adaptive_thinking); + assert_eq!(model.mode, AnthropicModelMode::AdaptiveThinking); + } -#[test] -fn custom_mode_default_disables_thinking() { - let model = Model::Custom { - name: "my-custom-model".to_string(), - max_tokens: 8192, - display_name: None, - tool_override: None, - cache_configuration: None, - max_output_tokens: None, - default_temperature: None, - extra_beta_headers: vec![], - mode: AnthropicModelMode::Default, - }; - assert!(!model.supports_thinking()); - assert!(!model.supports_adaptive_thinking()); + #[test] + fn from_listed_picks_thinking_mode_when_only_enabled_supported() { + let entry = listed_entry( + "claude-test-thinking", + ModelCapabilities { + thinking: Some(ThinkingCapability { + supported: true, + types: Some(ThinkingTypes { + adaptive: SupportedCapability { supported: false }, + enabled: SupportedCapability { supported: true }, + }), + }), + ..Default::default() + }, + ); + let model = Model::from_listed(entry); + assert!(model.supports_thinking); + assert!(!model.supports_adaptive_thinking); + assert!(matches!(model.mode, AnthropicModelMode::Thinking { .. })); + } + + #[test] + fn from_listed_default_mode_when_no_thinking() { + let entry = listed_entry("claude-test-default", ModelCapabilities::default()); + let model = Model::from_listed(entry); + assert!(!model.supports_thinking); + assert!(!model.supports_adaptive_thinking); + assert_eq!(model.mode, AnthropicModelMode::Default); + } + + #[test] + fn from_listed_collects_supported_effort_levels() { + let entry = listed_entry( + "claude-test-effort", + ModelCapabilities { + effort: Some(EffortCapability { + supported: true, + low: Some(SupportedCapability { supported: true }), + medium: Some(SupportedCapability { supported: false }), + high: Some(SupportedCapability { supported: true }), + max: Some(SupportedCapability { supported: true }), + xhigh: Some(SupportedCapability { supported: true }), + }), + ..Default::default() + }, + ); + let model = Model::from_listed(entry); + assert_eq!( + &model.supported_effort_levels, + &[Effort::Low, Effort::High, Effort::XHigh, Effort::Max] + ); + } } #[test] diff --git a/crates/language_models/src/provider/anthropic.rs b/crates/language_models/src/provider/anthropic.rs index 7f19b81d6dc..c4ea3dcfcde 100644 --- a/crates/language_models/src/provider/anthropic.rs +++ b/crates/language_models/src/provider/anthropic.rs @@ -17,7 +17,6 @@ use language_model::{ }; use settings::{Settings, SettingsStore}; use std::sync::{Arc, LazyLock}; -use strum::IntoEnumIterator; use ui::{ButtonLink, ConfiguredApiCard, List, ListBulletItem, prelude::*}; use ui_input::InputField; use util::ResultExt; @@ -46,6 +45,9 @@ static API_KEY_ENV_VAR: LazyLock = env_var!(API_KEY_ENV_VAR_NAME); pub struct State { api_key_state: ApiKeyState, credentials_provider: Arc, + http_client: Arc, + fetched_models: Vec, + fetch_models_task: Option>>, } impl State { @@ -56,24 +58,68 @@ impl State { fn set_api_key(&mut self, api_key: Option, cx: &mut Context) -> Task> { let credentials_provider = self.credentials_provider.clone(); let api_url = AnthropicLanguageModelProvider::api_url(cx); - self.api_key_state.store( + let should_fetch_models = api_key.is_some(); + let task = self.api_key_state.store( api_url, api_key, |this| &mut this.api_key_state, credentials_provider, cx, - ) + ); + self.fetched_models.clear(); + cx.spawn(async move |this, cx| { + let result = task.await; + if result.is_ok() && should_fetch_models { + this.update(cx, |this, cx| this.restart_fetch_models_task(cx)) + .ok(); + } + result + }) } fn authenticate(&mut self, cx: &mut Context) -> Task> { let credentials_provider = self.credentials_provider.clone(); let api_url = AnthropicLanguageModelProvider::api_url(cx); - self.api_key_state.load_if_needed( + let task = self.api_key_state.load_if_needed( api_url, |this| &mut this.api_key_state, credentials_provider, cx, - ) + ); + + cx.spawn(async move |this, cx| { + let result = task.await; + if result.is_ok() { + this.update(cx, |this, cx| this.restart_fetch_models_task(cx)) + .ok(); + } + result + }) + } + + fn fetch_models(&mut self, cx: &mut Context) -> Task> { + let http_client = self.http_client.clone(); + let api_url = AnthropicLanguageModelProvider::api_url(cx); + let Some(api_key) = self.api_key_state.key(&api_url) else { + return Task::ready(Err(anyhow::anyhow!( + "cannot fetch Anthropic models without an API key" + ))); + }; + + cx.spawn(async move |this, cx| { + let models = + anthropic::list_models(http_client.as_ref(), &api_url, api_key.as_ref()).await?; + + this.update(cx, |this, cx| { + this.fetched_models = models; + cx.notify(); + }) + }) + } + + fn restart_fetch_models_task(&mut self, cx: &mut Context) { + let task = self.fetch_models(cx); + self.fetch_models_task.replace(task); } } @@ -84,21 +130,33 @@ impl AnthropicLanguageModelProvider { cx: &mut App, ) -> Self { let state = cx.new(|cx| { - cx.observe_global::(|this: &mut State, cx| { - let credentials_provider = this.credentials_provider.clone(); - let api_url = Self::api_url(cx); - this.api_key_state.handle_url_change( - api_url, - |this| &mut this.api_key_state, - credentials_provider, - cx, - ); - cx.notify(); + cx.observe_global::({ + let mut last_api_url = Self::api_url(cx); + move |this: &mut State, cx| { + let credentials_provider = this.credentials_provider.clone(); + let api_url = Self::api_url(cx); + let url_changed = api_url != last_api_url; + last_api_url = api_url.clone(); + this.api_key_state.handle_url_change( + api_url, + |this| &mut this.api_key_state, + credentials_provider, + cx, + ); + if url_changed { + this.fetched_models.clear(); + this.authenticate(cx).detach(); + } + cx.notify(); + } }) .detach(); State { api_key_state: ApiKeyState::new(Self::api_url(cx), (*API_KEY_ENV_VAR).clone()), credentials_provider, + http_client: http_client.clone(), + fetched_models: Vec::new(), + fetch_models_task: None, } }); @@ -107,7 +165,7 @@ impl AnthropicLanguageModelProvider { fn create_language_model(&self, model: anthropic::Model) -> Arc { Arc::new(AnthropicModel { - id: LanguageModelId::from(model.id().to_string()), + id: LanguageModelId::from(model.id.to_string()), model, state: self.state.clone(), http_client: self.http_client.clone(), @@ -150,58 +208,44 @@ impl LanguageModelProvider for AnthropicLanguageModelProvider { IconOrSvg::Icon(IconName::AiAnthropic) } - fn default_model(&self, _cx: &App) -> Option> { - Some(self.create_language_model(anthropic::Model::default())) - } - - fn default_fast_model(&self, _cx: &App) -> Option> { - Some(self.create_language_model(anthropic::Model::default_fast())) - } - - fn recommended_models(&self, _cx: &App) -> Vec> { - [anthropic::Model::ClaudeSonnet4_6] - .into_iter() + fn default_model(&self, cx: &App) -> Option> { + let fetched = self.state.read(cx).fetched_models.clone(); + // Pick the highest-version Sonnet we know about; otherwise the first + // Claude model returned. Returning `None` until the fetch completes + // matches the Ollama provider's behavior. + pick_preferred_model(&fetched, &["claude-sonnet-", "claude-opus-", "claude-"]) .map(|model| self.create_language_model(model)) - .collect() + } + + fn default_fast_model(&self, cx: &App) -> Option> { + let fetched = self.state.read(cx).fetched_models.clone(); + pick_preferred_model(&fetched, &["claude-haiku-", "claude-"]) + .map(|model| self.create_language_model(model)) + } + + fn recommended_models(&self, cx: &App) -> Vec> { + let fetched = self.state.read(cx).fetched_models.clone(); + pick_preferred_model(&fetched, &["claude-sonnet-"]) + .map(|model| vec![self.create_language_model(model)]) + .unwrap_or_default() } fn provided_models(&self, cx: &App) -> Vec> { - let mut models = BTreeMap::default(); + let mut models: BTreeMap = BTreeMap::default(); - // Add base models from anthropic::Model::iter() - for model in anthropic::Model::iter() { - if !matches!(model, anthropic::Model::Custom { .. }) { - models.insert(model.id().to_string(), model); - } + // Models reported by Anthropic's `/v1/models` endpoint are the + // primary source. The list will be empty until authentication has + // succeeded and the first fetch completes. + for model in &self.state.read(cx).fetched_models { + models.insert(model.id.to_string(), model.clone()); } - // Override with available models from settings - for model in &AnthropicLanguageModelProvider::settings(cx).available_models { - models.insert( - model.name.clone(), - anthropic::Model::Custom { - name: model.name.clone(), - display_name: model.display_name.clone(), - max_tokens: model.max_tokens, - tool_override: model.tool_override.clone(), - cache_configuration: model.cache_configuration.as_ref().map(|config| { - anthropic::AnthropicModelCacheConfiguration { - max_cache_anchors: config.max_cache_anchors, - should_speculate: config.should_speculate, - min_total_token: config.min_total_token, - } - }), - max_output_tokens: model.max_output_tokens, - default_temperature: model.default_temperature, - extra_beta_headers: model.extra_beta_headers.clone(), - mode: match model.mode.unwrap_or_default() { - settings::ModelMode::Default => AnthropicModelMode::Default, - settings::ModelMode::Thinking { budget_tokens } => { - AnthropicModelMode::Thinking { budget_tokens } - } - }, - }, - ); + // User-defined `available_models` from settings can either add + // entirely new entries or override fields on a fetched model with + // the same id (e.g. enable Fast mode or set a tool override). + for available in &AnthropicLanguageModelProvider::settings(cx).available_models { + let model = available_model_to_anthropic_model(available); + models.insert(model.id.to_string(), model); } models @@ -234,6 +278,70 @@ impl LanguageModelProvider for AnthropicLanguageModelProvider { } } +/// Pick the model from `models` whose id starts with the earliest matching +/// prefix in `preferred_prefixes`. Within a single prefix bucket the model +/// with the lexicographically greatest id wins, which roughly corresponds to +/// the highest version since Anthropic ids embed dated suffixes. +fn pick_preferred_model( + models: &[anthropic::Model], + preferred_prefixes: &[&str], +) -> Option { + for prefix in preferred_prefixes { + let candidate = models + .iter() + .filter(|m| m.id.starts_with(prefix)) + .max_by(|a, b| a.id.cmp(&b.id)); + if let Some(model) = candidate { + return Some(model.clone()); + } + } + None +} + +/// Convert a settings-defined `available_models` entry into an `anthropic::Model`. +fn available_model_to_anthropic_model(available: &AvailableModel) -> anthropic::Model { + let mode = match available.mode.unwrap_or_default() { + settings::ModelMode::Default => AnthropicModelMode::Default, + settings::ModelMode::Thinking { budget_tokens } => { + AnthropicModelMode::Thinking { budget_tokens } + } + }; + let supports_thinking = matches!( + mode, + AnthropicModelMode::Thinking { .. } | AnthropicModelMode::AdaptiveThinking + ); + let supports_adaptive_thinking = matches!(mode, AnthropicModelMode::AdaptiveThinking); + + anthropic::Model { + display_name: available + .display_name + .clone() + .unwrap_or_else(|| available.name.clone()), + id: available.name.clone(), + max_input_tokens: available.max_tokens, + max_output_tokens: available.max_output_tokens.unwrap_or(4_096), + default_temperature: available.default_temperature.unwrap_or(1.0), + mode, + supports_thinking, + supports_adaptive_thinking, + supports_images: true, + supports_speed: false, + supported_effort_levels: if supports_adaptive_thinking { + vec![ + anthropic::Effort::Low, + anthropic::Effort::Medium, + anthropic::Effort::High, + anthropic::Effort::XHigh, + anthropic::Effort::Max, + ] + } else { + vec![] + }, + tool_override: available.tool_override.clone(), + extra_beta_headers: available.extra_beta_headers.clone(), + } +} + pub struct AnthropicModel { id: LanguageModelId, model: anthropic::Model, @@ -288,7 +396,7 @@ impl LanguageModel for AnthropicModel { } fn name(&self) -> LanguageModelName { - LanguageModelName::from(self.model.display_name().to_string()) + LanguageModelName::from(self.model.display_name.clone()) } fn provider_id(&self) -> LanguageModelProviderId { @@ -304,7 +412,7 @@ impl LanguageModel for AnthropicModel { } fn supports_images(&self) -> bool { - true + self.model.supports_images } fn supports_streaming_tools(&self) -> bool { @@ -320,44 +428,37 @@ impl LanguageModel for AnthropicModel { } fn supports_thinking(&self) -> bool { - self.model.supports_thinking() + self.model.supports_thinking } fn supports_fast_mode(&self) -> bool { - self.model.supports_speed() + self.model.supports_speed } fn supported_effort_levels(&self) -> Vec { - if self.model.supports_adaptive_thinking() { - vec![ + self.model + .supported_effort_levels + .iter() + .map(|e| { + let is_default = matches!(e, anthropic::Effort::High); + let (name, value) = match e { + anthropic::Effort::Low => ("Low".into(), "low".into()), + anthropic::Effort::Medium => ("Medium".into(), "medium".into()), + anthropic::Effort::High => ("High".into(), "high".into()), + anthropic::Effort::XHigh => ("XHigh".into(), "xhigh".into()), + anthropic::Effort::Max => ("Max".into(), "max".into()), + }; language_model::LanguageModelEffortLevel { - name: "Low".into(), - value: "low".into(), - is_default: false, - }, - language_model::LanguageModelEffortLevel { - name: "Medium".into(), - value: "medium".into(), - is_default: false, - }, - language_model::LanguageModelEffortLevel { - name: "High".into(), - value: "high".into(), - is_default: true, - }, - language_model::LanguageModelEffortLevel { - name: "Max".into(), - value: "max".into(), - is_default: false, - }, - ] - } else { - Vec::new() - } + name, + value, + is_default, + } + }) + .collect::>() } fn telemetry_id(&self) -> String { - format!("anthropic/{}", self.model.id()) + format!("anthropic/{}", self.model.id) } fn api_key(&self, cx: &App) -> Option { @@ -368,11 +469,11 @@ impl LanguageModel for AnthropicModel { } fn max_token_count(&self) -> u64 { - self.model.max_token_count() + self.model.max_input_tokens } fn max_output_tokens(&self) -> Option { - Some(self.model.max_output_tokens()) + Some(self.model.max_output_tokens) } fn stream_completion( @@ -386,14 +487,16 @@ impl LanguageModel for AnthropicModel { LanguageModelCompletionError, >, > { + let has_tools = !request.tools.is_empty(); + let request_id = self.model.request_id(has_tools).to_string(); let mut request = into_anthropic( request, - self.model.request_id().into(), - self.model.default_temperature(), - self.model.max_output_tokens(), - self.model.mode(), + request_id, + self.model.default_temperature, + self.model.max_output_tokens, + self.model.mode.clone(), ); - if !self.model.supports_speed() { + if !self.model.supports_speed { request.speed = None; } let request = self.stream_completion(request, cx); @@ -405,13 +508,7 @@ impl LanguageModel for AnthropicModel { } fn cache_configuration(&self) -> Option { - self.model - .cache_configuration() - .map(|config| LanguageModelCacheConfiguration { - max_cache_anchors: config.max_cache_anchors, - should_speculate: config.should_speculate, - min_total_token: config.min_total_token, - }) + None } } diff --git a/docs/src/ai/llm-providers.md b/docs/src/ai/llm-providers.md index 1f8104bde24..07ba2d938f5 100644 --- a/docs/src/ai/llm-providers.md +++ b/docs/src/ai/llm-providers.md @@ -181,11 +181,6 @@ You can add custom models to the Anthropic provider by adding the following to y "display_name": "Sonnet 2024-June", "max_tokens": 128000, "max_output_tokens": 2560, - "cache_configuration": { - "max_cache_anchors": 10, - "min_total_token": 10000, - "should_speculate": false - }, "tool_override": "some-model-that-supports-toolcalling" } ]