anthropic: Dynamically fetch models from /models (#56397)

Most compelling reason to make this change is that we don't have to ship
a new Zed binary if Anthropic releases a new model

Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- anthropic: Dynamically fetch available models from Anthropic API

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
This commit is contained in:
Bennet Bo Fenner 2026-05-11 15:19:20 +02:00 committed by GitHub
parent 40d444413f
commit ee309a0000
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 495 additions and 508 deletions

View file

@ -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<String>,
/// Override this model with a different Anthropic model for tool calls.
tool_override: Option<String>,
/// Indicates whether this custom model supports caching.
cache_configuration: Option<AnthropicModelCacheConfiguration>,
max_output_tokens: Option<u64>,
default_temperature: Option<f32>,
#[serde(default)]
extra_beta_headers: Vec<String>,
#[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<ThinkingCapability>,
#[serde(default)]
pub image_input: Option<SupportedCapability>,
#[serde(default)]
pub effort: Option<EffortCapability>,
}
#[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<ThinkingTypes>,
}
#[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<SupportedCapability>,
#[serde(default)]
pub medium: Option<SupportedCapability>,
#[serde(default)]
pub high: Option<SupportedCapability>,
#[serde(default)]
pub max: Option<SupportedCapability>,
#[serde(default)]
pub xhigh: Option<SupportedCapability>,
}
#[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<Effort>,
/// A model id to substitute when invoking tools, used for models that
/// don't support tool calling natively.
pub tool_override: Option<String>,
/// Extra `Anthropic-Beta` header values to send with each request.
pub extra_beta_headers: Vec<String>,
}
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<Self> {
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<AnthropicModelCacheConfiguration> {
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<String> {
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<ModelCapabilities>,
}
#[derive(Debug, Deserialize)]
struct ListModelsResponse {
data: Vec<ListModelEntry>,
}
/// 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<Vec<Model>> {
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::<Vec<_>>();
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<ApiError> 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]

View file

@ -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<EnvVar> = env_var!(API_KEY_ENV_VAR_NAME);
pub struct State {
api_key_state: ApiKeyState,
credentials_provider: Arc<dyn CredentialsProvider>,
http_client: Arc<dyn HttpClient>,
fetched_models: Vec<anthropic::Model>,
fetch_models_task: Option<Task<Result<()>>>,
}
impl State {
@ -56,24 +58,68 @@ impl State {
fn set_api_key(&mut self, api_key: Option<String>, cx: &mut Context<Self>) -> Task<Result<()>> {
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<Self>) -> Task<Result<(), AuthenticateError>> {
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<Self>) -> Task<Result<()>> {
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<Self>) {
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::<SettingsStore>(|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::<SettingsStore>({
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<dyn LanguageModel> {
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<Arc<dyn LanguageModel>> {
Some(self.create_language_model(anthropic::Model::default()))
}
fn default_fast_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {
Some(self.create_language_model(anthropic::Model::default_fast()))
}
fn recommended_models(&self, _cx: &App) -> Vec<Arc<dyn LanguageModel>> {
[anthropic::Model::ClaudeSonnet4_6]
.into_iter()
fn default_model(&self, cx: &App) -> Option<Arc<dyn LanguageModel>> {
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<Arc<dyn LanguageModel>> {
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<Arc<dyn LanguageModel>> {
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<Arc<dyn LanguageModel>> {
let mut models = BTreeMap::default();
let mut models: BTreeMap<String, anthropic::Model> = 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<anthropic::Model> {
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<language_model::LanguageModelEffortLevel> {
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::<Vec<_>>()
}
fn telemetry_id(&self) -> String {
format!("anthropic/{}", self.model.id())
format!("anthropic/{}", self.model.id)
}
fn api_key(&self, cx: &App) -> Option<String> {
@ -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<u64> {
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<LanguageModelCacheConfiguration> {
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
}
}

View file

@ -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"
}
]