| @@ -2,13 +2,13 @@ use std::io; · 1 unmodified line |
| 2 | 2 | use std::str::FromStr; |
| 3 | 3 | use std::time::Duration; |
| 4 | 4 | |
| 5 | | −use anyhow::{Context as _, Result, anyhow}; |
| 5 | +use anyhow::{Context as _, Result}; |
| 6 | 6 | use chrono::{DateTime, Utc}; |
| 7 | 7 | use futures::{AsyncBufReadExt, AsyncReadExt, StreamExt, io::BufReader, stream::BoxStream}; |
| 8 | 8 | use http_client::http::{self, HeaderMap, HeaderValue}; |
| 9 | 9 | use http_client::{AsyncBody, HttpClient, Method, Request as HttpRequest, StatusCode}; |
| 10 | 10 | use serde::{Deserialize, Serialize}; |
| 11 | | −use strum::{EnumIter, EnumString}; |
| 11 | +use strum::EnumString; |
| 12 | 12 | use thiserror::Error; |
| 13 | 13 | |
| 14 | 14 | pub mod batches; |
| @@ -16,14 +16,6 @@ pub mod completion; · 1 unmodified line |
| 16 | 16 | |
| 17 | 17 | pub const ANTHROPIC_API_URL: &str = "https://api.anthropic.com"; |
| 18 | 18 | |
| 19 | | −#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] |
| 20 | | −#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] |
| 21 | | −pub struct AnthropicModelCacheConfiguration { |
| 22 | | − pub min_total_token: u64, |
| 23 | | − pub should_speculate: bool, |
| 24 | | − pub max_cache_anchors: usize, |
| 25 | | −} |
| 26 | | − |
| 27 | 19 | #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] |
| 28 | 20 | #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] |
| 29 | 21 | pub enum AnthropicModelMode { |
| @@ -35,343 +27,152 @@ pub enum AnthropicModelMode { · 5 unmodified lines |
| 35 | 27 | AdaptiveThinking, |
| 36 | 28 | } |
| 37 | 29 | |
| 38 | | −#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] |
| 39 | | −#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, EnumIter)] |
| 40 | | −pub enum Model { |
| 41 | | − #[serde( |
| 42 | | − rename = "claude-opus-4", |
| 43 | | − alias = "claude-opus-4-latest", |
| 44 | | − alias = "claude-opus-4-thinking", |
| 45 | | − alias = "claude-opus-4-thinking-latest" |
| 46 | | − )] |
| 47 | | − ClaudeOpus4, |
| 48 | | − #[serde( |
| 49 | | − rename = "claude-opus-4-1", |
| 50 | | − alias = "claude-opus-4-1-latest", |
| 51 | | − alias = "claude-opus-4-1-thinking", |
| 52 | | − alias = "claude-opus-4-1-thinking-latest" |
| 53 | | − )] |
| 54 | | − ClaudeOpus4_1, |
| 55 | | − #[serde( |
| 56 | | − rename = "claude-opus-4-5", |
| 57 | | − alias = "claude-opus-4-5-latest", |
| 58 | | − alias = "claude-opus-4-5-thinking", |
| 59 | | − alias = "claude-opus-4-5-thinking-latest" |
| 60 | | − )] |
| 61 | | − ClaudeOpus4_5, |
| 62 | | − #[serde( |
| 63 | | − rename = "claude-opus-4-6", |
| 64 | | − alias = "claude-opus-4-6-latest", |
| 65 | | − alias = "claude-opus-4-6-1m-context", |
| 66 | | − alias = "claude-opus-4-6-1m-context-latest", |
| 67 | | − alias = "claude-opus-4-6-thinking", |
| 68 | | − alias = "claude-opus-4-6-thinking-latest", |
| 69 | | − alias = "claude-opus-4-6-1m-context-thinking", |
| 70 | | − alias = "claude-opus-4-6-1m-context-thinking-latest" |
| 71 | | − )] |
| 72 | | − ClaudeOpus4_6, |
| 73 | | − #[serde( |
| 74 | | − rename = "claude-opus-4-7", |
| 75 | | − alias = "claude-opus-4-7-latest", |
| 76 | | − alias = "claude-opus-4-7-1m-context", |
| 77 | | − alias = "claude-opus-4-7-1m-context-latest", |
| 78 | | − alias = "claude-opus-4-7-thinking", |
| 79 | | − alias = "claude-opus-4-7-thinking-latest", |
| 80 | | − alias = "claude-opus-4-7-1m-context-thinking", |
| 81 | | − alias = "claude-opus-4-7-1m-context-thinking-latest" |
| 82 | | − )] |
| 83 | | − ClaudeOpus4_7, |
| 84 | | − #[serde( |
| 85 | | − rename = "claude-sonnet-4", |
| 86 | | − alias = "claude-sonnet-4-latest", |
| 87 | | − alias = "claude-sonnet-4-thinking", |
| 88 | | − alias = "claude-sonnet-4-thinking-latest" |
| 89 | | − )] |
| 90 | | − ClaudeSonnet4, |
| 91 | | − #[serde( |
| 92 | | − rename = "claude-sonnet-4-5", |
| 93 | | − alias = "claude-sonnet-4-5-latest", |
| 94 | | − alias = "claude-sonnet-4-5-thinking", |
| 95 | | − alias = "claude-sonnet-4-5-thinking-latest" |
| 96 | | − )] |
| 97 | | − ClaudeSonnet4_5, |
| 98 | | − #[default] |
| 99 | | − #[serde( |
| 100 | | − rename = "claude-sonnet-4-6", |
| 101 | | − alias = "claude-sonnet-4-6-latest", |
| 102 | | − alias = "claude-sonnet-4-6-1m-context", |
| 103 | | − alias = "claude-sonnet-4-6-1m-context-latest", |
| 104 | | − alias = "claude-sonnet-4-6-thinking", |
| 105 | | − alias = "claude-sonnet-4-6-thinking-latest", |
| 106 | | − alias = "claude-sonnet-4-6-1m-context-thinking", |
| 107 | | − alias = "claude-sonnet-4-6-1m-context-thinking-latest" |
| 108 | | − )] |
| 109 | | − ClaudeSonnet4_6, |
| 110 | | − #[serde( |
| 111 | | − rename = "claude-haiku-4-5", |
| 112 | | − alias = "claude-haiku-4-5-latest", |
| 113 | | − alias = "claude-haiku-4-5-thinking", |
| 114 | | − alias = "claude-haiku-4-5-thinking-latest" |
| 115 | | − )] |
| 116 | | − ClaudeHaiku4_5, |
| 117 | | − #[serde(rename = "claude-3-haiku", alias = "claude-3-haiku-latest")] |
| 118 | | − Claude3Haiku, |
| 119 | | − #[serde(rename = "custom")] |
| 120 | | − Custom { |
| 121 | | − name: String, |
| 122 | | − max_tokens: u64, |
| 123 | | − /// The name displayed in the UI, such as in the agent panel model dropdown menu. |
| 124 | | − display_name: Option<String>, |
| 125 | | − /// Override this model with a different Anthropic model for tool calls. |
| 126 | | − tool_override: Option<String>, |
| 127 | | − /// Indicates whether this custom model supports caching. |
| 128 | | − cache_configuration: Option<AnthropicModelCacheConfiguration>, |
| 129 | | − max_output_tokens: Option<u64>, |
| 130 | | − default_temperature: Option<f32>, |
| 131 | | − #[serde(default)] |
| 132 | | − extra_beta_headers: Vec<String>, |
| 133 | | − #[serde(default)] |
| 134 | | − mode: AnthropicModelMode, |
| 135 | | − }, |
| 30 | +/// Capabilities reported by the Anthropic models endpoint for a given model. |
| 31 | +#[derive(Clone, Debug, Default, Deserialize)] |
| 32 | +pub struct ModelCapabilities { |
| 33 | + #[serde(default)] |
| 34 | + pub thinking: Option<ThinkingCapability>, |
| 35 | + #[serde(default)] |
| 36 | + pub image_input: Option<SupportedCapability>, |
| 37 | + #[serde(default)] |
| 38 | + pub effort: Option<EffortCapability>, |
| 136 | 39 | } |
| 137 | 40 | |
| 138 | | −impl Model { |
| 139 | | − pub fn default_fast() -> Self { |
| 140 | | − Self::ClaudeHaiku4_5 |
| 141 | | − } |
| 142 | | − |
| 143 | | − pub fn from_id(id: &str) -> Result<Self> { |
| 144 | | − if id.starts_with("claude-opus-4-7") { |
| 145 | | − return Ok(Self::ClaudeOpus4_7); |
| 146 | | − } |
| 147 | | − |
| 148 | | − if id.starts_with("claude-opus-4-6") { |
| 149 | | − return Ok(Self::ClaudeOpus4_6); |
| 150 | | − } |
| 151 | | − |
| 152 | | − if id.starts_with("claude-opus-4-5") { |
| 153 | | − return Ok(Self::ClaudeOpus4_5); |
| 154 | | − } |
| 155 | | − |
| 156 | | − if id.starts_with("claude-opus-4-1") { |
| 157 | | − return Ok(Self::ClaudeOpus4_1); |
| 158 | | − } |
| 159 | | − |
| 160 | | − if id.starts_with("claude-opus-4") { |
| 161 | | − return Ok(Self::ClaudeOpus4); |
| 162 | | − } |
| 163 | | − |
| 164 | | − if id.starts_with("claude-sonnet-4-6") { |
| 165 | | − return Ok(Self::ClaudeSonnet4_6); |
| 166 | | − } |
| 167 | | − |
| 168 | | − if id.starts_with("claude-sonnet-4-5") { |
| 169 | | − return Ok(Self::ClaudeSonnet4_5); |
| 170 | | − } |
| 171 | | − |
| 172 | | − if id.starts_with("claude-sonnet-4") { |
| 173 | | − return Ok(Self::ClaudeSonnet4); |
| 174 | | − } |
| 175 | | − |
| 176 | | − if id.starts_with("claude-haiku-4-5") { |
| 177 | | − return Ok(Self::ClaudeHaiku4_5); |
| 178 | | − } |
| 179 | | − |
| 180 | | − if id.starts_with("claude-3-haiku") { |
| 181 | | − return Ok(Self::Claude3Haiku); |
| 182 | | − } |
| 183 | | − |
| 184 | | − Err(anyhow!("invalid model ID: {id}")) |
| 185 | | − } |
| 186 | | − |
| 187 | | − pub fn id(&self) -> &str { |
| 188 | | − match self { |
| 189 | | − Self::ClaudeOpus4 => "claude-opus-4-latest", |
| 190 | | − Self::ClaudeOpus4_1 => "claude-opus-4-1-latest", |
| 191 | | − Self::ClaudeOpus4_5 => "claude-opus-4-5-latest", |
| 192 | | − Self::ClaudeOpus4_6 => "claude-opus-4-6-latest", |
| 193 | | − Self::ClaudeOpus4_7 => "claude-opus-4-7-latest", |
| 194 | | − Self::ClaudeSonnet4 => "claude-sonnet-4-latest", |
| 195 | | − Self::ClaudeSonnet4_5 => "claude-sonnet-4-5-latest", |
| 196 | | − Self::ClaudeSonnet4_6 => "claude-sonnet-4-6-latest", |
| 197 | | − Self::ClaudeHaiku4_5 => "claude-haiku-4-5-latest", |
| 198 | | − Self::Claude3Haiku => "claude-3-haiku-20240307", |
| 199 | | − Self::Custom { name, .. } => name, |
| 200 | | − } |
| 201 | | − } |
| 202 | | − |
| 203 | | − /// The id of the model that should be used for making API requests |
| 204 | | − pub fn request_id(&self) -> &str { |
| 205 | | − match self { |
| 206 | | − Self::ClaudeOpus4 => "claude-opus-4-20250514", |
| 207 | | − Self::ClaudeOpus4_1 => "claude-opus-4-1-20250805", |
| 208 | | − Self::ClaudeOpus4_5 => "claude-opus-4-5-20251101", |
| 209 | | − Self::ClaudeOpus4_6 => "claude-opus-4-6", |
| 210 | | − Self::ClaudeOpus4_7 => "claude-opus-4-7", |
| 211 | | − Self::ClaudeSonnet4 => "claude-sonnet-4-20250514", |
| 212 | | − Self::ClaudeSonnet4_5 => "claude-sonnet-4-5-20250929", |
| 213 | | − Self::ClaudeSonnet4_6 => "claude-sonnet-4-6", |
| 214 | | − Self::ClaudeHaiku4_5 => "claude-haiku-4-5-20251001", |
| 215 | | − Self::Claude3Haiku => "claude-3-haiku-20240307", |
| 216 | | − Self::Custom { name, .. } => name, |
| 217 | | − } |
| 218 | | − } |
| 41 | +#[derive(Clone, Debug, Default, Deserialize)] |
| 42 | +pub struct SupportedCapability { |
| 43 | + #[serde(default)] |
| 44 | + pub supported: bool, |
| 45 | +} |
| 219 | 46 | |
| 220 | | − pub fn display_name(&self) -> &str { |
| 221 | | − match self { |
| 222 | | − Self::ClaudeOpus4 => "Claude Opus 4", |
| 223 | | − Self::ClaudeOpus4_1 => "Claude Opus 4.1", |
| 224 | | − Self::ClaudeOpus4_5 => "Claude Opus 4.5", |
| 225 | | − Self::ClaudeOpus4_6 => "Claude Opus 4.6", |
| 226 | | − Self::ClaudeOpus4_7 => "Claude Opus 4.7", |
| 227 | | − Self::ClaudeSonnet4 => "Claude Sonnet 4", |
| 228 | | − Self::ClaudeSonnet4_5 => "Claude Sonnet 4.5", |
| 229 | | − Self::ClaudeSonnet4_6 => "Claude Sonnet 4.6", |
| 230 | | − Self::ClaudeHaiku4_5 => "Claude Haiku 4.5", |
| 231 | | − Self::Claude3Haiku => "Claude 3 Haiku", |
| 232 | | − Self::Custom { |
| 233 | | − name, display_name, .. |
| 234 | | − } => display_name.as_ref().unwrap_or(name), |
| 235 | | − } |
| 236 | | − } |
| 47 | +#[derive(Clone, Debug, Default, Deserialize)] |
| 48 | +pub struct ThinkingCapability { |
| 49 | + #[serde(default)] |
| 50 | + pub supported: bool, |
| 51 | + #[serde(default)] |
| 52 | + pub types: Option<ThinkingTypes>, |
| 53 | +} |
| 237 | 54 | |
| 238 | | − pub fn cache_configuration(&self) -> Option<AnthropicModelCacheConfiguration> { |
| 239 | | − match self { |
| 240 | | − Self::ClaudeOpus4 |
| 241 | | − | Self::ClaudeOpus4_1 |
| 242 | | − | Self::ClaudeOpus4_5 |
| 243 | | − | Self::ClaudeOpus4_6 |
| 244 | | − | Self::ClaudeOpus4_7 |
| 245 | | − | Self::ClaudeSonnet4 |
| 246 | | − | Self::ClaudeSonnet4_5 |
| 247 | | − | Self::ClaudeSonnet4_6 |
| 248 | | − | Self::ClaudeHaiku4_5 |
| 249 | | − | Self::Claude3Haiku => Some(AnthropicModelCacheConfiguration { |
| 250 | | − min_total_token: 2_048, |
| 251 | | − should_speculate: true, |
| 252 | | − max_cache_anchors: 4, |
| 253 | | − }), |
| 254 | | − Self::Custom { |
| 255 | | − cache_configuration, |
| 256 | | − .. |
| 257 | | − } => cache_configuration.clone(), |
| 258 | | − } |
| 259 | | − } |
| 55 | +#[derive(Clone, Debug, Default, Deserialize)] |
| 56 | +pub struct ThinkingTypes { |
| 57 | + #[serde(default)] |
| 58 | + pub adaptive: SupportedCapability, |
| 59 | + #[serde(default)] |
| 60 | + pub enabled: SupportedCapability, |
| 61 | +} |
| 260 | 62 | |
| 261 | | − pub fn max_token_count(&self) -> u64 { |
| 262 | | − match self { |
| 263 | | − Self::ClaudeOpus4 |
| 264 | | − | Self::ClaudeOpus4_1 |
| 265 | | − | Self::ClaudeOpus4_5 |
| 266 | | − | Self::ClaudeSonnet4 |
| 267 | | − | Self::ClaudeSonnet4_5 |
| 268 | | − | Self::ClaudeHaiku4_5 |
| 269 | | − | Self::Claude3Haiku => 200_000, |
| 270 | | − Self::ClaudeOpus4_6 | Self::ClaudeOpus4_7 | Self::ClaudeSonnet4_6 => 1_000_000, |
| 271 | | − Self::Custom { max_tokens, .. } => *max_tokens, |
| 272 | | − } |
| 273 | | − } |
| 63 | +#[derive(Clone, Debug, Default, Deserialize)] |
| 64 | +pub struct EffortCapability { |
| 65 | + #[serde(default)] |
| 66 | + pub supported: bool, |
| 67 | + #[serde(default)] |
| 68 | + pub low: Option<SupportedCapability>, |
| 69 | + #[serde(default)] |
| 70 | + pub medium: Option<SupportedCapability>, |
| 71 | + #[serde(default)] |
| 72 | + pub high: Option<SupportedCapability>, |
| 73 | + #[serde(default)] |
| 74 | + pub max: Option<SupportedCapability>, |
| 75 | + #[serde(default)] |
| 76 | + pub xhigh: Option<SupportedCapability>, |
| 77 | +} |
| 274 | 78 | |
| 275 | | − pub fn max_output_tokens(&self) -> u64 { |
| 276 | | − match self { |
| 277 | | − Self::ClaudeOpus4 | Self::ClaudeOpus4_1 => 32_000, |
| 278 | | − Self::ClaudeOpus4_5 |
| 279 | | − | Self::ClaudeSonnet4 |
| 280 | | − | Self::ClaudeSonnet4_5 |
| 281 | | − | Self::ClaudeSonnet4_6 |
| 282 | | − | Self::ClaudeHaiku4_5 => 64_000, |
| 283 | | − Self::ClaudeOpus4_6 | Self::ClaudeOpus4_7 => 128_000, |
| 284 | | − Self::Claude3Haiku => 4_096, |
| 285 | | − Self::Custom { |
| 286 | | − max_output_tokens, .. |
| 287 | | − } => max_output_tokens.unwrap_or(4_096), |
| 288 | | − } |
| 289 | | − } |
| 79 | +#[derive(Clone, Debug, PartialEq)] |
| 80 | +pub struct Model { |
| 81 | + pub id: String, |
| 82 | + pub display_name: String, |
| 83 | + pub max_input_tokens: u64, |
| 84 | + pub max_output_tokens: u64, |
| 85 | + pub default_temperature: f32, |
| 86 | + pub mode: AnthropicModelMode, |
| 87 | + pub supports_thinking: bool, |
| 88 | + pub supports_adaptive_thinking: bool, |
| 89 | + pub supports_images: bool, |
| 90 | + pub supports_speed: bool, |
| 91 | + pub supported_effort_levels: Vec<Effort>, |
| 92 | + /// A model id to substitute when invoking tools, used for models that |
| 93 | + /// don't support tool calling natively. |
| 94 | + pub tool_override: Option<String>, |
| 95 | + /// Extra `Anthropic-Beta` header values to send with each request. |
| 96 | + pub extra_beta_headers: Vec<String>, |
| 97 | +} |
| 290 | 98 | |
| 291 | | − pub fn default_temperature(&self) -> f32 { |
| 292 | | − match self { |
| 293 | | − Self::ClaudeOpus4 |
| 294 | | − | Self::ClaudeOpus4_1 |
| 295 | | − | Self::ClaudeOpus4_5 |
| 296 | | − | Self::ClaudeOpus4_6 |
| 297 | | − | Self::ClaudeOpus4_7 |
| 298 | | − | Self::ClaudeSonnet4 |
| 299 | | − | Self::ClaudeSonnet4_5 |
| 300 | | − | Self::ClaudeSonnet4_6 |
| 301 | | − | Self::ClaudeHaiku4_5 |
| 302 | | − | Self::Claude3Haiku => 1.0, |
| 303 | | − Self::Custom { |
| 304 | | − default_temperature, |
| 305 | | − .. |
| 306 | | − } => default_temperature.unwrap_or(1.0), |
| 99 | +impl Model { |
| 100 | + /// Construct a `Model` from an entry returned by the `/v1/models` listing endpoint. |
| 101 | + pub fn from_listed(entry: ListModelEntry) -> Self { |
| 102 | + let supports_thinking = entry |
| 103 | + .capabilities |
| 104 | + .as_ref() |
| 105 | + .and_then(|t| t.thinking.as_ref()) |
| 106 | + .map(|t| t.supported) |
| 107 | + .unwrap_or(false); |
| 108 | + let supports_adaptive_thinking = entry |
| 109 | + .capabilities |
| 110 | + .as_ref() |
| 111 | + .and_then(|t| t.thinking.as_ref()) |
| 112 | + .and_then(|t| t.types.as_ref()) |
| 113 | + .map(|types| types.adaptive.supported) |
| 114 | + .unwrap_or(false); |
| 115 | + let supports_images = entry |
| 116 | + .capabilities |
| 117 | + .as_ref() |
| 118 | + .and_then(|c| c.image_input.as_ref()) |
| 119 | + .map(|c| c.supported) |
| 120 | + .unwrap_or(false); |
| 121 | + |
| 122 | + let mut supported_effort_levels = Vec::new(); |
| 123 | + if let Some(effort) = entry.capabilities.as_ref().and_then(|e| e.effort.as_ref()) { |
| 124 | + // The `xhigh` effort level reported by the API has no |
| 125 | + // corresponding `Effort` variant in the request enum, so it is |
| 126 | + // intentionally dropped here. |
| 127 | + for (level, supported) in [ |
| 128 | + (Effort::Low, effort.low.as_ref()), |
| 129 | + (Effort::Medium, effort.medium.as_ref()), |
| 130 | + (Effort::High, effort.high.as_ref()), |
| 131 | + (Effort::XHigh, effort.xhigh.as_ref()), |
| 132 | + (Effort::Max, effort.max.as_ref()), |
| 133 | + ] { |
| 134 | + if supported.map(|c| c.supported).unwrap_or(false) { |
| 135 | + supported_effort_levels.push(level); |
| 136 | + } |
| 137 | + } |
| 307 | 138 | } |
| 308 | | − } |
| 309 | 139 | |
| 310 | | − pub fn mode(&self) -> AnthropicModelMode { |
| 311 | | − match self { |
| 312 | | − Self::Custom { mode, .. } => mode.clone(), |
| 313 | | − _ if self.supports_adaptive_thinking() => AnthropicModelMode::AdaptiveThinking, |
| 314 | | − _ if self.supports_thinking() => AnthropicModelMode::Thinking { |
| 140 | + let mode = if supports_adaptive_thinking { |
| 141 | + AnthropicModelMode::AdaptiveThinking |
| 142 | + } else if supports_thinking { |
| 143 | + AnthropicModelMode::Thinking { |
| 315 | 144 | budget_tokens: Some(4_096), |
| 316 | | − }, |
| 317 | | − _ => AnthropicModelMode::Default, |
| 318 | | − } |
| 319 | | − } |
| 320 | | − |
| 321 | | − pub fn supports_thinking(&self) -> bool { |
| 322 | | − match self { |
| 323 | | − Self::Custom { mode, .. } => { |
| 324 | | − matches!( |
| 325 | | − mode, |
| 326 | | − AnthropicModelMode::Thinking { .. } | AnthropicModelMode::AdaptiveThinking |
| 327 | | − ) |
| 328 | 145 | } |
| 329 | | − _ => matches!( |
| 330 | | − self, |
| 331 | | − Self::ClaudeOpus4 |
| 332 | | − | Self::ClaudeOpus4_1 |
| 333 | | − | Self::ClaudeOpus4_5 |
| 334 | | − | Self::ClaudeOpus4_6 |
| 335 | | − | Self::ClaudeOpus4_7 |
| 336 | | − | Self::ClaudeSonnet4 |
| 337 | | − | Self::ClaudeSonnet4_5 |
| 338 | | − | Self::ClaudeSonnet4_6 |
| 339 | | − | Self::ClaudeHaiku4_5 |
| 340 | | − ), |
| 341 | | − } |
| 342 | | − } |
| 146 | + } else { |
| 147 | + AnthropicModelMode::Default |
| 148 | + }; |
| 343 | 149 | |
| 344 | | − pub fn supports_speed(&self) -> bool { |
| 345 | | − matches!(self, Self::ClaudeOpus4_6 | Self::ClaudeSonnet4_6) |
| 346 | | − } |
| 150 | + let supports_speed = entry.id == "claude-opus-4-6"; |
| 347 | 151 | |
| 348 | | − pub fn supports_adaptive_thinking(&self) -> bool { |
| 349 | | − match self { |
| 350 | | − Self::Custom { mode, .. } => matches!(mode, AnthropicModelMode::AdaptiveThinking), |
| 351 | | − _ => matches!( |
| 352 | | − self, |
| 353 | | − Self::ClaudeOpus4_6 | Self::ClaudeOpus4_7 | Self::ClaudeSonnet4_6 |
| 354 | | − ), |
| 152 | + Self { |
| 153 | + display_name: entry.display_name, |
| 154 | + id: entry.id, |
| 155 | + max_input_tokens: entry.max_input_tokens, |
| 156 | + max_output_tokens: entry.max_tokens, |
| 157 | + default_temperature: 1.0, |
| 158 | + mode, |
| 159 | + supports_thinking, |
| 160 | + supports_adaptive_thinking, |
| 161 | + supports_images, |
| 162 | + supports_speed, |
| 163 | + supported_effort_levels, |
| 164 | + tool_override: None, |
| 165 | + extra_beta_headers: Vec::new(), |
| 355 | 166 | } |
| 356 | 167 | } |
| 357 | 168 | |
| 358 | 169 | pub fn beta_headers(&self) -> Option<String> { |
| 359 | | − let mut headers = vec![]; |
| 360 | | − |
| 361 | | − match self { |
| 362 | | − Self::Custom { |
| 363 | | − extra_beta_headers, .. |
| 364 | | − } => { |
| 365 | | − headers.extend( |
| 366 | | − extra_beta_headers |
| 367 | | − .iter() |
| 368 | | − .filter(|header| !header.trim().is_empty()) |
| 369 | | − .cloned(), |
| 370 | | − ); |
| 371 | | − } |
| 372 | | − _ => {} |
| 373 | | − } |
| 374 | | − |
| 170 | + let headers: Vec<&str> = self |
| 171 | + .extra_beta_headers |
| 172 | + .iter() |
| 173 | + .map(|h| h.trim()) |
| 174 | + .filter(|h| !h.is_empty()) |
| 175 | + .collect(); |
| 375 | 176 | if headers.is_empty() { |
| 376 | 177 | None |
| 377 | 178 | } else { |
| @@ -379,15 +180,11 @@ impl Model { · 1 unmodified line |
| 379 | 180 | } |
| 380 | 181 | } |
| 381 | 182 | |
| 382 | | − pub fn tool_model_id(&self) -> &str { |
| 383 | | − if let Self::Custom { |
| 384 | | − tool_override: Some(tool_override), |
| 385 | | − .. |
| 386 | | − } = self |
| 387 | | − { |
| 388 | | − tool_override |
| 183 | + pub fn request_id(&self, has_tools: bool) -> &str { |
| 184 | + if has_tools { |
| 185 | + self.tool_override.as_deref().unwrap_or(&self.id) |
| 389 | 186 | } else { |
| 390 | | − self.request_id() |
| 187 | + &self.id |
| 391 | 188 | } |
| 392 | 189 | } |
| 393 | 190 | } |
| @@ -405,6 +202,73 @@ pub async fn stream_completion( · 11 unmodified lines |
| 405 | 202 | .map(|output| output.0) |
| 406 | 203 | } |
| 407 | 204 | |
| 205 | +/// A raw model entry returned by the Anthropic models listing endpoint. |
| 206 | +#[derive(Clone, Debug, Deserialize)] |
| 207 | +pub struct ListModelEntry { |
| 208 | + pub id: String, |
| 209 | + pub display_name: String, |
| 210 | + pub max_input_tokens: u64, |
| 211 | + pub max_tokens: u64, |
| 212 | + #[serde(default)] |
| 213 | + pub capabilities: Option<ModelCapabilities>, |
| 214 | +} |
| 215 | + |
| 216 | +#[derive(Debug, Deserialize)] |
| 217 | +struct ListModelsResponse { |
| 218 | + data: Vec<ListModelEntry>, |
| 219 | +} |
| 220 | + |
| 221 | +/// Fetch the list of models available to the current API key. The returned |
| 222 | +/// models are constructed by feeding each raw entry through |
| 223 | +/// [`Model::from_listed`]. |
| 224 | +/// |
| 225 | +/// See https://docs.claude.com/en/api/models-list. |
| 226 | +pub async fn list_models( |
| 227 | + client: &dyn HttpClient, |
| 228 | + api_url: &str, |
| 229 | + api_key: &str, |
| 230 | +) -> Result<Vec<Model>> { |
| 231 | + let uri = format!("{api_url}/v1/models?limit=1000"); |
| 232 | + |
| 233 | + let request = HttpRequest::builder() |
| 234 | + .method(Method::GET) |
| 235 | + .uri(uri) |
| 236 | + .header("Anthropic-Version", "2023-06-01") |
| 237 | + .header("X-Api-Key", api_key.trim()) |
| 238 | + .header("Accept", "application/json") |
| 239 | + .body(AsyncBody::default()) |
| 240 | + .context("failed to build Anthropic models list request")?; |
| 241 | + |
| 242 | + let mut response = client |
| 243 | + .send(request) |
| 244 | + .await |
| 245 | + .context("failed to send Anthropic models list request")?; |
| 246 | + |
| 247 | + let mut body = String::new(); |
| 248 | + response |
| 249 | + .body_mut() |
| 250 | + .read_to_string(&mut body) |
| 251 | + .await |
| 252 | + .context("failed to read Anthropic models list response")?; |
| 253 | + |
| 254 | + anyhow::ensure!( |
| 255 | + response.status().is_success(), |
| 256 | + "failed to list Anthropic models: {} {}", |
| 257 | + response.status(), |
| 258 | + body, |
| 259 | + ); |
| 260 | + |
| 261 | + let parsed: ListModelsResponse = |
| 262 | + serde_json::from_str(&body).context("failed to parse Anthropic models list response")?; |
| 263 | + |
| 264 | + let models = parsed |
| 265 | + .data |
| 266 | + .into_iter() |
| 267 | + .map(Model::from_listed) |
| 268 | + .collect::<Vec<_>>(); |
| 269 | + Ok(models) |
| 270 | +} |
| 271 | + |
| 408 | 272 | /// Generate completion without streaming. |
| 409 | 273 | pub async fn non_streaming_completion( |
| 410 | 274 | client: &dyn HttpClient, |
| @@ -780,13 +644,14 @@ pub enum AdaptiveThinkingDisplay { · 369 unmodified lines |
| 780 | 644 | Summarized, |
| 781 | 645 | } |
| 782 | 646 | |
| 783 | | −#[derive(Debug, Clone, Copy, Serialize, Deserialize, EnumString)] |
| 647 | +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, EnumString)] |
| 784 | 648 | #[serde(rename_all = "snake_case")] |
| 785 | 649 | #[strum(serialize_all = "snake_case")] |
| 786 | 650 | pub enum Effort { |
| 787 | 651 | Low, |
| 788 | 652 | Medium, |
| 789 | 653 | High, |
| 654 | + XHigh, |
| 790 | 655 | Max, |
| 791 | 656 | } |
| 792 | 657 | |
| @@ -1095,63 +960,93 @@ impl From<ApiError> for language_model_core::LanguageModelCompletionError { · 302 unmodified lines |
| 1095 | 960 | } |
| 1096 | 961 | } |
| 1097 | 962 | |
| 1098 | | −#[test] |
| 1099 | | −fn custom_mode_thinking_is_preserved() { |
| 1100 | | − let model = Model::Custom { |
| 1101 | | − name: "my-custom-model".to_string(), |
| 1102 | | − max_tokens: 8192, |
| 1103 | | − display_name: None, |
| 1104 | | − tool_override: None, |
| 1105 | | − cache_configuration: None, |
| 1106 | | − max_output_tokens: None, |
| 1107 | | − default_temperature: None, |
| 1108 | | − extra_beta_headers: vec![], |
| 1109 | | − mode: AnthropicModelMode::Thinking { |
| 1110 | | − budget_tokens: Some(2048), |
| 1111 | | − }, |
| 1112 | | − }; |
| 1113 | | − assert_eq!( |
| 1114 | | − model.mode(), |
| 1115 | | − AnthropicModelMode::Thinking { |
| 1116 | | − budget_tokens: Some(2048) |
| 963 | +#[cfg(test)] |
| 964 | +mod tests { |
| 965 | + use super::*; |
| 966 | + |
| 967 | + fn listed_entry(id: &str, capabilities: ModelCapabilities) -> ListModelEntry { |
| 968 | + ListModelEntry { |
| 969 | + id: id.to_string(), |
| 970 | + display_name: id.to_string(), |
| 971 | + max_input_tokens: 200_000, |
| 972 | + max_tokens: 64_000, |
| 973 | + capabilities: Some(capabilities), |
| 1117 | 974 | } |
| 1118 | | − ); |
| 1119 | | − assert!(model.supports_thinking()); |
| 1120 | | −} |
| 975 | + } |
| 1121 | 976 | |
| 1122 | | −#[test] |
| 1123 | | −fn custom_mode_adaptive_is_preserved() { |
| 1124 | | − let model = Model::Custom { |
| 1125 | | − name: "my-custom-model".to_string(), |
| 1126 | | − max_tokens: 8192, |
| 1127 | | − display_name: None, |
| 1128 | | − tool_override: None, |
| 1129 | | − cache_configuration: None, |
| 1130 | | − max_output_tokens: None, |
| 1131 | | − default_temperature: None, |
| 1132 | | − extra_beta_headers: vec![], |
| 1133 | | − mode: AnthropicModelMode::AdaptiveThinking, |
| 1134 | | − }; |
| 1135 | | − assert_eq!(model.mode(), AnthropicModelMode::AdaptiveThinking); |
| 1136 | | − assert!(model.supports_adaptive_thinking()); |
| 1137 | | − assert!(model.supports_thinking()); |
| 1138 | | −} |
| 977 | + #[test] |
| 978 | + fn from_listed_picks_adaptive_thinking_mode() { |
| 979 | + let entry = listed_entry( |
| 980 | + "claude-test-adaptive", |
| 981 | + ModelCapabilities { |
| 982 | + thinking: Some(ThinkingCapability { |
| 983 | + supported: true, |
| 984 | + types: Some(ThinkingTypes { |
| 985 | + adaptive: SupportedCapability { supported: true }, |
| 986 | + enabled: SupportedCapability { supported: true }, |
| 987 | + }), |
| 988 | + }), |
| 989 | + ..Default::default() |
| 990 | + }, |
| 991 | + ); |
| 992 | + let model = Model::from_listed(entry); |
| 993 | + assert!(model.supports_thinking); |
| 994 | + assert!(model.supports_adaptive_thinking); |
| 995 | + assert_eq!(model.mode, AnthropicModelMode::AdaptiveThinking); |
| 996 | + } |
| 1139 | 997 | |
| 1140 | | −#[test] |
| 1141 | | −fn custom_mode_default_disables_thinking() { |
| 1142 | | − let model = Model::Custom { |
| 1143 | | − name: "my-custom-model".to_string(), |
| 1144 | | − max_tokens: 8192, |
| 1145 | | − display_name: None, |
| 1146 | | − tool_override: None, |
| 1147 | | − cache_configuration: None, |
| 1148 | | − max_output_tokens: None, |
| 1149 | | − default_temperature: None, |
| 1150 | | − extra_beta_headers: vec![], |
| 1151 | | − mode: AnthropicModelMode::Default, |
| 1152 | | − }; |
| 1153 | | − assert!(!model.supports_thinking()); |
| 1154 | | − assert!(!model.supports_adaptive_thinking()); |
| 998 | + #[test] |
| 999 | + fn from_listed_picks_thinking_mode_when_only_enabled_supported() { |
| 1000 | + let entry = listed_entry( |
| 1001 | + "claude-test-thinking", |
| 1002 | + ModelCapabilities { |
| 1003 | + thinking: Some(ThinkingCapability { |
| 1004 | + supported: true, |
| 1005 | + types: Some(ThinkingTypes { |
| 1006 | + adaptive: SupportedCapability { supported: false }, |
| 1007 | + enabled: SupportedCapability { supported: true }, |
| 1008 | + }), |
| 1009 | + }), |
| 1010 | + ..Default::default() |
| 1011 | + }, |
| 1012 | + ); |
| 1013 | + let model = Model::from_listed(entry); |
| 1014 | + assert!(model.supports_thinking); |
| 1015 | + assert!(!model.supports_adaptive_thinking); |
| 1016 | + assert!(matches!(model.mode, AnthropicModelMode::Thinking { .. })); |
| 1017 | + } |
| 1018 | + |
| 1019 | + #[test] |
| 1020 | + fn from_listed_default_mode_when_no_thinking() { |
| 1021 | + let entry = listed_entry("claude-test-default", ModelCapabilities::default()); |
| 1022 | + let model = Model::from_listed(entry); |
| 1023 | + assert!(!model.supports_thinking); |
| 1024 | + assert!(!model.supports_adaptive_thinking); |
| 1025 | + assert_eq!(model.mode, AnthropicModelMode::Default); |
| 1026 | + } |
| 1027 | + |
| 1028 | + #[test] |
| 1029 | + fn from_listed_collects_supported_effort_levels() { |
| 1030 | + let entry = listed_entry( |
| 1031 | + "claude-test-effort", |
| 1032 | + ModelCapabilities { |
| 1033 | + effort: Some(EffortCapability { |
| 1034 | + supported: true, |
| 1035 | + low: Some(SupportedCapability { supported: true }), |
| 1036 | + medium: Some(SupportedCapability { supported: false }), |
| 1037 | + high: Some(SupportedCapability { supported: true }), |
| 1038 | + max: Some(SupportedCapability { supported: true }), |
| 1039 | + xhigh: Some(SupportedCapability { supported: true }), |
| 1040 | + }), |
| 1041 | + ..Default::default() |
| 1042 | + }, |
| 1043 | + ); |
| 1044 | + let model = Model::from_listed(entry); |
| 1045 | + assert_eq!( |
| 1046 | + &model.supported_effort_levels, |
| 1047 | + &[Effort::Low, Effort::High, Effort::XHigh, Effort::Max] |
| 1048 | + ); |
| 1049 | + } |
| 1155 | 1050 | } |
| 1156 | 1051 | |
| 1157 | 1052 | #[test] |