use std::io; use std::str::FromStr; use std::sync::Arc; use std::time::Duration; 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, CustomHeaders, HttpClient, Method, Request as HttpRequest, RequestBuilderExt, StatusCode, }; use serde::{Deserialize, Serialize}; use strum::EnumString; use thiserror::Error; pub mod batches; pub mod completion; pub const ANTHROPIC_API_URL: &str = "https://api.anthropic.com"; pub const FAST_MODE_BETA_HEADER: &str = "fast-mode-2026-02-01"; pub fn supports_fast_mode(model_id: &str) -> bool { matches!( model_id, "claude-opus-5-5" | "claude-opus-5" | "claude-opus-4-8" ) } /// Model IDs where adaptive thinking runs by default when a request omits the /// `thinking` field, and where thinking must instead be turned off with an /// explicit `thinking: {"type": "disabled"}`. /// /// On earlier Opus models omitting `thinking` means thinking is off; Claude /// Opus 5 flipped that default, as did Claude Haiku 5.5. Claude Fable 5 and /// Claude Mythos 5 also think by default, but they reject `{"type": /// "disabled"}` with a 400 error, so they are deliberately excluded here /// (thinking cannot be turned off for them at all). /// /// pub fn requires_explicit_thinking_opt_out(model_id: &str) -> bool { matches!(model_id, "claude-opus-5" | "claude-haiku-5-5") } pub const FABLE_MODEL_ID_PREFIX: &str = "claude-fable-5"; pub const FABLE_FALLBACK_MODEL_ID: &str = "claude-opus-4-8"; pub const THINKING_BINDING_CONTROLS_BETA_HEADER: &str = "thinking-binding-controls-2026-08-01"; pub fn binds_thinking_blocks_to_prefix(model_id: &str) -> bool { matches!( model_id, "claude-opus-5-5" | "claude-fable-5-1" | "claude-sonnet-5-5" | "claude-haiku-5-5" ) } pub fn supports_forced_tool_use(model_id: &str) -> bool { !matches!( model_id, "claude-opus-5-5" | "claude-fable-5-1" | "claude-mythos-5-1" | "claude-sonnet-5-5" ) } /// pub const COMPACTION_BETA_HEADER: &str = "compact-2026-01-12"; /// The smallest input-token trigger Anthropic accepts for compaction. pub const MIN_COMPACTION_TRIGGER_TOKENS: u64 = 50_000; #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] pub enum AnthropicModelMode { #[default] Default, Thinking { budget_tokens: Option, }, AdaptiveThinking, } /// 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 supports_compaction: 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 { /// 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); let mut supported_effort_levels = Vec::new(); if let Some(effort) = entry.capabilities.as_ref().and_then(|e| e.effort.as_ref()) { 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); } } } let mode = if supports_adaptive_thinking { AnthropicModelMode::AdaptiveThinking } else if supports_thinking { AnthropicModelMode::Thinking { budget_tokens: Some(4_096), } } else { AnthropicModelMode::Default }; let supports_speed = supports_fast_mode(&entry.id); // let supports_compaction = matches!( entry.id.as_str(), "claude-opus-5-5" | "claude-fable-5-1" | "claude-fable-5" | "claude-mythos-5-1" | "claude-mythos-5" | "claude-mythos-preview" | "claude-opus-5" | "claude-opus-4-8" | "claude-opus-4-7" | "claude-opus-4-6" | "claude-sonnet-5-5" | "claude-sonnet-5" | "claude-sonnet-4-6" | "claude-haiku-5-5" ); let mut extra_beta_headers = Vec::new(); if supports_speed { extra_beta_headers.push(FAST_MODE_BETA_HEADER.to_string()); } if supports_compaction { extra_beta_headers.push(COMPACTION_BETA_HEADER.to_string()); } if binds_thinking_blocks_to_prefix(&entry.id) { extra_beta_headers.push(THINKING_BINDING_CONTROLS_BETA_HEADER.to_string()); } 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, supports_compaction, supported_effort_levels, tool_override: None, extra_beta_headers, } } pub fn beta_headers(&self) -> Option { 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 { Some(headers.join(",")) } } pub fn request_id(&self, has_tools: bool) -> &str { if has_tools { self.tool_override.as_deref().unwrap_or(&self.id) } else { &self.id } } } /// Generate completion with streaming. pub async fn stream_completion( client: &dyn HttpClient, api_url: &str, api_key: &str, request: Request, beta_headers: Option, extra_headers: &CustomHeaders, ) -> Result>, AnthropicError> { stream_completion_with_rate_limit_info( client, api_url, api_key, request, beta_headers, extra_headers, ) .await .map(|output| output.0) } /// A validated 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 ApiListModelEntry { id: String, display_name: String, max_input_tokens: Option, max_tokens: Option, #[serde(default)] capabilities: Option, } impl ApiListModelEntry { fn into_listed(self) -> Option { let Self { id, display_name, max_input_tokens, max_tokens, capabilities, } = self; let missing_fields = match (&max_input_tokens, &max_tokens) { (None, None) => Some("`max_input_tokens` and `max_tokens`"), (None, Some(_)) => Some("`max_input_tokens`"), (Some(_), None) => Some("`max_tokens`"), (Some(_), Some(_)) => None, }; if let Some(missing_fields) = missing_fields { log::error!( "Filtering out Anthropic model `{id}` because the API returned null for {missing_fields}" ); return None; } Some(ListModelEntry { id, display_name, max_input_tokens: max_input_tokens?, max_tokens: max_tokens?, capabilities, }) } } #[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, extra_headers: &CustomHeaders, ) -> Result, AnthropicError> { 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") .extra_headers(extra_headers) .body(AsyncBody::default()) .map_err(AnthropicError::BuildRequestBody)?; let host = request.uri().host().unwrap_or(api_url).to_owned(); let mut response = client .send(request) .await .map_err(|error| AnthropicError::HttpSend { host, error })?; if !response.status().is_success() { let rate_limits = RateLimitInfo::from_headers(response.headers()); return Err(handle_error_response(response, rate_limits).await); } let mut body = String::new(); response .body_mut() .read_to_string(&mut body) .await .map_err(AnthropicError::ReadResponse)?; let parsed: ListModelsResponse = serde_json::from_str(&body).map_err(AnthropicError::DeserializeResponse)?; let models = parsed .data .into_iter() .filter_map(ApiListModelEntry::into_listed) .map(Model::from_listed) .collect::>(); Ok(models) } /// Generate completion without streaming. pub async fn non_streaming_completion( client: &dyn HttpClient, api_url: &str, api_key: &str, request: Request, beta_headers: Option, extra_headers: &CustomHeaders, ) -> Result { let (mut response, rate_limits) = send_request( client, api_url, api_key, &request, beta_headers, extra_headers, ) .await?; if response.status().is_success() { let mut body = String::new(); response .body_mut() .read_to_string(&mut body) .await .map_err(AnthropicError::ReadResponse)?; serde_json::from_str(&body).map_err(AnthropicError::DeserializeResponse) } else { Err(handle_error_response(response, rate_limits).await) } } /// Estimates input tokens without generating a message. /// /// Anthropic's estimate may differ slightly from usage reported during generation. pub async fn count_input_tokens( client: &dyn HttpClient, api_url: &str, api_key: &str, request: CountTokensRequest, beta_headers: Option, extra_headers: &CustomHeaders, ) -> Result { let (mut response, rate_limits) = send_request_to_route( client, api_url, "/v1/messages/count_tokens", api_key, request, beta_headers, extra_headers, ) .await?; if !response.status().is_success() { return Err(handle_error_response(response, rate_limits).await); } let mut body = String::new(); response .body_mut() .read_to_string(&mut body) .await .map_err(AnthropicError::ReadResponse)?; #[derive(Deserialize)] struct CountTokensResponse { input_tokens: u64, } serde_json::from_str::(&body) .map(|response| response.input_tokens) .map_err(AnthropicError::DeserializeResponse) } async fn send_request( client: &dyn HttpClient, api_url: &str, api_key: &str, request: impl Serialize, beta_headers: Option, extra_headers: &CustomHeaders, ) -> Result<(http::Response, RateLimitInfo), AnthropicError> { send_request_to_route( client, api_url, "/v1/messages", api_key, request, beta_headers, extra_headers, ) .await } async fn send_request_to_route( client: &dyn HttpClient, api_url: &str, route: &str, api_key: &str, request: impl Serialize, beta_headers: Option, extra_headers: &CustomHeaders, ) -> Result<(http::Response, RateLimitInfo), AnthropicError> { let uri = format!("{api_url}{route}"); let mut request_builder = HttpRequest::builder() .method(Method::POST) .uri(uri) .header("Anthropic-Version", "2023-06-01") .header("X-Api-Key", api_key.trim()) .header("Content-Type", "application/json"); if let Some(beta_headers) = beta_headers { request_builder = request_builder.header("Anthropic-Beta", beta_headers); } let serialized_request = serde_json::to_string(&request).map_err(AnthropicError::SerializeRequest)?; let request = request_builder .extra_headers(extra_headers) .body(AsyncBody::from(serialized_request)) .map_err(AnthropicError::BuildRequestBody)?; let host = request.uri().host().unwrap_or(api_url).to_owned(); let response = client .send(request) .await .map_err(|error| AnthropicError::HttpSend { host, error })?; let rate_limits = RateLimitInfo::from_headers(response.headers()); Ok((response, rate_limits)) } async fn handle_error_response( mut response: http::Response, rate_limits: RateLimitInfo, ) -> AnthropicError { let status = response.status(); if status.as_u16() == 529 { return AnthropicError::ServerOverloaded { status, retry_after: rate_limits.retry_after, }; } if let Some(retry_after) = rate_limits.retry_after { return AnthropicError::RateLimit { status, retry_after, }; } let mut body = String::new(); let read_result = response .body_mut() .read_to_string(&mut body) .await .map_err(AnthropicError::ReadResponse); if let Err(err) = read_result { return err; } match serde_json::from_str::(&body) { Ok(Event::Error { error }) => AnthropicError::ApiError { status: Some(status), error, }, Ok(_) | Err(_) => AnthropicError::HttpResponseError { status_code: status, message: body, }, } } /// An individual rate limit. #[derive(Debug)] pub struct RateLimit { pub limit: usize, pub remaining: usize, pub reset: DateTime, } impl RateLimit { fn from_headers(resource: &str, headers: &HeaderMap) -> Result { let limit = get_header(&format!("anthropic-ratelimit-{resource}-limit"), headers)?.parse()?; let remaining = get_header( &format!("anthropic-ratelimit-{resource}-remaining"), headers, )? .parse()?; let reset = DateTime::parse_from_rfc3339(get_header( &format!("anthropic-ratelimit-{resource}-reset"), headers, )?)? .to_utc(); Ok(Self { limit, remaining, reset, }) } } /// #[derive(Debug)] pub struct RateLimitInfo { pub retry_after: Option, pub requests: Option, pub tokens: Option, pub input_tokens: Option, pub output_tokens: Option, } impl RateLimitInfo { fn from_headers(headers: &HeaderMap) -> Self { // Check if any rate limit headers exist let has_rate_limit_headers = headers .keys() .any(|k| k == "retry-after" || k.as_str().starts_with("anthropic-ratelimit-")); if !has_rate_limit_headers { return Self { retry_after: None, requests: None, tokens: None, input_tokens: None, output_tokens: None, }; } Self { retry_after: parse_retry_after(headers), requests: RateLimit::from_headers("requests", headers).ok(), tokens: RateLimit::from_headers("tokens", headers).ok(), input_tokens: RateLimit::from_headers("input-tokens", headers).ok(), output_tokens: RateLimit::from_headers("output-tokens", headers).ok(), } } } /// Parses the Retry-After header value as an integer number of seconds (anthropic always uses /// seconds). Note that other services might specify an HTTP date or some other format for this /// header. Returns `None` if the header is not present or cannot be parsed. pub fn parse_retry_after(headers: &HeaderMap) -> Option { headers .get("retry-after") .and_then(|v| v.to_str().ok()) .and_then(|v| v.parse::().ok()) .map(Duration::from_secs) } fn get_header<'a>(key: &str, headers: &'a HeaderMap) -> anyhow::Result<&'a str> { Ok(headers .get(key) .with_context(|| format!("missing header `{key}`"))? .to_str()?) } pub async fn stream_completion_with_rate_limit_info( client: &dyn HttpClient, api_url: &str, api_key: &str, request: Request, beta_headers: Option, extra_headers: &CustomHeaders, ) -> Result< ( BoxStream<'static, Result>, Option, ), AnthropicError, > { let request = StreamingRequest { base: request, stream: true, }; let (response, rate_limits) = send_request( client, api_url, api_key, &request, beta_headers, extra_headers, ) .await?; if response.status().is_success() { let reader = BufReader::new(response.into_body()); let stream = reader .lines() .filter_map(|line| async move { match line { Ok(line) => { let line = line .strip_prefix("data: ") .or_else(|| line.strip_prefix("data:"))?; // Some proxies and gateways append `data: [DONE]` as a // stream-termination sentinel (an OpenAI convention). // It is not part of the Anthropic streaming spec and is // not valid JSON, so skip it to avoid a spurious // deserialization error. if line.trim() == "[DONE]" { return None; } match serde_json::from_str(line) { Ok(response) => Some(Ok(response)), Err(error) => Some(Err(AnthropicError::DeserializeResponse(error))), } } Err(error) => Some(Err(AnthropicError::ReadResponse(error))), } }) .boxed(); Ok((stream, Some(rate_limits))) } else { Err(handle_error_response(response, rate_limits).await) } } #[derive(Debug, Serialize, Deserialize, Copy, Clone)] #[serde(rename_all = "lowercase")] pub enum CacheControlType { Ephemeral, } #[derive(Debug, Serialize, Deserialize, Copy, Clone)] pub enum CacheTtl { /// Anthropic's default ephemeral TTL (currently 5 minutes). Refreshes for /// free on every cache hit. #[serde(rename = "5m")] FiveMinutes, /// Anthropic's extended ephemeral TTL (currently 1 hour). Costs 2x base /// input tokens to write, but persists across longer idle gaps. #[serde(rename = "1h")] OneHour, } #[derive(Debug, Serialize, Deserialize, Copy, Clone)] pub struct CacheControl { #[serde(rename = "type")] pub cache_type: CacheControlType, /// Omitted (None) means the API's default 5-minute TTL. Anthropic requires /// that cache entries with longer TTLs appear before shorter ones in the /// prefix order (tools → system → messages). #[serde(default, skip_serializing_if = "Option::is_none")] pub ttl: Option, } #[derive(Debug, Serialize, Deserialize)] pub struct Message { pub role: Role, pub content: Vec, } #[derive(Debug, Serialize, Deserialize, Eq, PartialEq, Hash)] #[serde(rename_all = "lowercase")] pub enum Role { User, Assistant, } #[derive(Debug, Serialize, Deserialize)] #[serde(tag = "type")] pub enum RequestContent { #[serde(rename = "text")] Text { text: String, #[serde(skip_serializing_if = "Option::is_none")] cache_control: Option, }, #[serde(rename = "thinking")] Thinking { thinking: String, signature: String, #[serde(skip_serializing_if = "Option::is_none")] cache_control: Option, }, #[serde(rename = "redacted_thinking")] RedactedThinking { data: String }, #[serde(rename = "image")] Image { source: ImageSource, #[serde(skip_serializing_if = "Option::is_none")] cache_control: Option, }, #[serde(rename = "tool_use")] ToolUse { id: String, name: String, input: serde_json::Value, #[serde(skip_serializing_if = "Option::is_none")] cache_control: Option, }, #[serde(rename = "tool_result")] ToolResult { tool_use_id: String, is_error: bool, content: ToolResultContent, #[serde(skip_serializing_if = "Option::is_none")] cache_control: Option, }, #[serde(rename = "compaction")] Compaction { content: Option>, /// Opaque metadata from a prior compaction that must be round-tripped /// verbatim for Anthropic to recognize the block. #[serde(default, skip_serializing_if = "Option::is_none")] encrypted_content: Option>, #[serde(skip_serializing_if = "Option::is_none")] cache_control: Option, }, } #[derive(Debug, Serialize, Deserialize)] #[serde(untagged)] pub enum ToolResultContent { Plain(String), Multipart(Vec), } #[derive(Debug, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "lowercase")] pub enum ToolResultPart { Text { text: String }, Image { source: ImageSource }, } #[derive(Debug, Serialize, Deserialize)] #[serde(tag = "type")] pub enum ResponseContent { #[serde(rename = "text")] Text { text: String }, #[serde(rename = "thinking")] Thinking { thinking: String }, #[serde(rename = "redacted_thinking")] RedactedThinking { data: String }, #[serde(rename = "tool_use")] ToolUse { id: String, name: String, input: serde_json::Value, }, #[serde(rename = "compaction")] Compaction { content: Option>, #[serde(default)] encrypted_content: Option>, }, } #[derive(Debug, Serialize, Deserialize)] pub struct ImageSource { #[serde(rename = "type")] pub source_type: String, pub media_type: String, pub data: String, } fn is_false(value: &bool) -> bool { !value } #[derive(Debug, Serialize, Deserialize)] pub struct Tool { pub name: String, pub description: String, pub input_schema: serde_json::Value, #[serde(default, skip_serializing_if = "is_false")] pub eager_input_streaming: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub cache_control: Option, } #[derive(Debug, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "lowercase")] pub enum ToolChoice { Auto, Any, Tool { name: String }, None, } #[derive(Debug, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "lowercase")] pub enum Thinking { Enabled { budget_tokens: Option, }, Adaptive { #[serde(default, skip_serializing_if = "Option::is_none")] display: Option, #[serde(default, skip_serializing_if = "Option::is_none")] block_binding: Option, }, /// Explicitly turns thinking off. Required by models where thinking runs /// by default (see [`requires_explicit_thinking_opt_out`]); only accepted /// at effort `high` or below. Disabled, /// The lowest thinking setting, for models that think by default but /// reject [`Thinking::Disabled`] (Claude Sonnet 5.5). #[serde(rename = "between_tools")] BetweenTools, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum AdaptiveThinkingDisplay { Omitted, Summarized, } /// Controls for models that bind thinking blocks to the request prefix (see /// [`binds_thinking_blocks_to_prefix`]). Requires the /// [`THINKING_BINDING_CONTROLS_BETA_HEADER`] beta header. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] pub struct ThinkingBlockBinding { pub prefix_mismatch_behavior: PrefixMismatchBehavior, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum PrefixMismatchBehavior { /// Drop an invalidated thinking block and continue, instead of rejecting /// the request with a 400. DropBlock, } #[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, #[serde(rename = "xhigh")] #[strum(serialize = "xhigh")] XHigh, Max, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OutputConfig { pub effort: Option, } #[derive(Debug, Serialize, Deserialize)] #[serde(untagged)] pub enum StringOrContents { String(String), Content(Vec), } /// Server-side context management configuration. /// /// #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ContextManagement { pub edits: Vec, } /// A context management edit strategy. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type")] pub enum ContextManagementEdit { #[serde(rename = "compact_20260112")] Compact { #[serde(default, skip_serializing_if = "Option::is_none")] trigger: Option, /// Stops after emitting the compaction block instead of continuing the response. #[serde(default, skip_serializing_if = "Option::is_none")] pause_after_compaction: Option, }, } /// When to trigger server-side compaction. #[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum CompactionTrigger { InputTokens { value: u64 }, } #[derive(Debug, Serialize, Deserialize)] pub struct Request { pub model: String, pub max_tokens: u64, pub messages: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub tools: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub thinking: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub tool_choice: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub system: Option, /// Top-level cache_control opts into Anthropic's automatic prompt caching. /// When set, Anthropic places the cache breakpoint on the last cacheable block /// in the request (covering tools + system + the full conversation prefix), so /// we don't have to micromanage per-block breakpoints ourselves. #[serde(default, skip_serializing_if = "Option::is_none")] pub cache_control: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub context_management: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub metadata: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub output_config: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub stop_sequences: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub speed: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub temperature: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub top_k: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub top_p: Option, } /// Input counted by Anthropic, excluding generation and service settings. #[derive(Serialize, Debug)] pub struct CountTokensRequest { pub model: String, pub messages: Vec, #[serde(skip_serializing_if = "Vec::is_empty")] pub tools: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub tool_choice: Option, #[serde(skip_serializing_if = "Option::is_none")] pub system: Option, #[serde(skip_serializing_if = "Option::is_none")] pub thinking: Option, #[serde(skip_serializing_if = "Option::is_none")] pub context_management: Option, } impl Request { pub fn into_count_tokens_request(self) -> CountTokensRequest { CountTokensRequest { model: self.model, messages: self.messages, tools: self.tools, tool_choice: self.tool_choice, system: self.system, thinking: self.thinking, context_management: self.context_management, } } /// Configures this request to stop after native compaction. /// /// Tool definitions remain in the request so the trigger observes the same /// context and prompt-cache prefix as normal generation. Tool choice is /// disabled because explicit compaction must only produce replacement /// context. /// /// Claude 4.6 and later reject requests ending in an assistant message as /// unsupported prefill, so completed conversations receive a final user /// turn that requests compaction. pub fn into_compact_request(mut self) -> Self { self.tool_choice = (!self.tools.is_empty()).then_some(ToolChoice::None); if self .messages .last() .is_some_and(|message| message.role == Role::Assistant) { self.messages.push(Message { role: Role::User, content: vec![RequestContent::Text { text: "Compact the conversation so far.".to_string(), cache_control: None, }], }); } self.context_management = Some(ContextManagement { edits: vec![ContextManagementEdit::Compact { trigger: Some(CompactionTrigger::InputTokens { value: MIN_COMPACTION_TRIGGER_TOKENS, }), pause_after_compaction: Some(true), }], }); self } } #[derive(Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Speed { #[default] Standard, Fast, } #[derive(Debug, Serialize, Deserialize)] pub struct StreamingRequest { #[serde(flatten)] pub base: Request, pub stream: bool, } #[derive(Debug, Serialize, Deserialize)] pub struct Metadata { pub user_id: Option, } #[derive(Debug, Serialize, Deserialize, Default)] pub struct Usage { #[serde(default, skip_serializing_if = "Option::is_none")] pub input_tokens: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub output_tokens: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub cache_creation_input_tokens: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub cache_read_input_tokens: Option, /// Per-sampling token counts returned when the compaction beta is enabled. /// /// The top-level token fields exclude compaction iterations, so total /// billable usage is the sum across all iterations. #[serde(default, skip_serializing_if = "Option::is_none")] pub iterations: Option>, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct UsageIteration { #[serde(rename = "type")] pub iteration_type: UsageIterationType, #[serde(default, skip_serializing_if = "Option::is_none")] pub input_tokens: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub output_tokens: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub cache_creation_input_tokens: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub cache_read_input_tokens: Option, } #[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum UsageIterationType { Compaction, Message, #[serde(other)] Unknown, } #[derive(Debug, Serialize, Deserialize)] pub struct Response { pub id: String, #[serde(rename = "type")] pub response_type: String, pub role: Role, pub content: Vec, pub model: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub stop_reason: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub stop_sequence: Option, pub usage: Usage, } #[derive(Debug, Serialize, Deserialize)] #[serde(tag = "type")] pub enum Event { #[serde(rename = "message_start")] MessageStart { message: Response }, #[serde(rename = "content_block_start")] ContentBlockStart { index: usize, content_block: ResponseContent, }, #[serde(rename = "content_block_delta")] ContentBlockDelta { index: usize, delta: ContentDelta }, #[serde(rename = "content_block_stop")] ContentBlockStop { index: usize }, #[serde(rename = "message_delta")] MessageDelta { delta: MessageDelta, usage: Usage }, #[serde(rename = "message_stop")] MessageStop, #[serde(rename = "ping")] Ping, #[serde(rename = "error")] Error { error: ApiError }, } #[derive(Debug, Serialize, Deserialize)] #[serde(tag = "type")] pub enum ContentDelta { #[serde(rename = "text_delta")] TextDelta { text: String }, #[serde(rename = "thinking_delta")] ThinkingDelta { thinking: String }, #[serde(rename = "signature_delta")] SignatureDelta { signature: String }, #[serde(rename = "input_json_delta")] InputJsonDelta { partial_json: String }, #[serde(rename = "compaction_delta")] CompactionDelta { content: Option>, #[serde(default)] encrypted_content: Option>, }, } #[derive(Debug, Serialize, Deserialize)] pub struct MessageDelta { pub stop_reason: Option, pub stop_sequence: Option, } #[derive(Debug)] pub enum AnthropicError { /// Failed to serialize the HTTP request body to JSON SerializeRequest(serde_json::Error), /// Failed to construct the HTTP request body BuildRequestBody(http::Error), /// Failed to send the HTTP request HttpSend { host: String, error: anyhow::Error }, /// Failed to deserialize the response from JSON DeserializeResponse(serde_json::Error), /// Failed to read from response stream ReadResponse(io::Error), /// HTTP error response from the API HttpResponseError { status_code: StatusCode, message: String, }, /// Rate limit exceeded RateLimit { status: StatusCode, retry_after: Duration, }, /// Server overloaded ServerOverloaded { status: StatusCode, retry_after: Option, }, /// API returned an error response ApiError { status: Option, error: ApiError, }, } #[derive(Debug, Serialize, Deserialize, Error)] #[error("Anthropic API Error: {error_type}: {message}")] pub struct ApiError { #[serde(rename = "type")] pub error_type: String, pub message: String, } /// An Anthropic API error code. /// #[derive(Debug, PartialEq, Eq, Clone, Copy, EnumString)] #[strum(serialize_all = "snake_case")] pub enum ApiErrorCode { /// 400 - `invalid_request_error`: There was an issue with the format or content of your request. InvalidRequestError, /// 401 - `authentication_error`: There's an issue with your API key. AuthenticationError, /// 402 - `billing_error`: There's an issue with the account's billing. BillingError, /// 403 - `permission_error`: Your API key does not have permission to use the specified resource. PermissionError, /// 404 - `not_found_error`: The requested resource was not found. NotFoundError, /// 409 - `conflict_error`: The request conflicts with the current state of the resource. ConflictError, /// 413 - `request_too_large`: Request exceeds the maximum allowed number of bytes. RequestTooLarge, /// 429 - `rate_limit_error`: Your account has hit a rate limit. RateLimitError, /// 504 - `timeout_error`: Anthropic's gateway timed out while processing the request. TimeoutError, /// 500 - `api_error`: An unexpected error has occurred internal to Anthropic's systems. ApiError, /// 529 - `overloaded_error`: Anthropic's API is temporarily overloaded. OverloadedError, } impl ApiError { pub fn code(&self) -> Option { ApiErrorCode::from_str(&self.error_type).ok() } pub fn is_rate_limit_error(&self) -> bool { matches!(self.error_type.as_str(), "rate_limit_error") } pub fn match_window_exceeded(&self) -> Option { let Some(ApiErrorCode::InvalidRequestError) = self.code() else { return None; }; parse_prompt_too_long(&self.message) } } pub fn parse_prompt_too_long(message: &str) -> Option { message .strip_prefix("prompt is too long: ")? .split_once(" tokens")? .0 .parse() .ok() } // -- Conversions from/to `language_model_core` types -- impl From for Speed { fn from(speed: language_model_core::Speed) -> Self { match speed { language_model_core::Speed::Standard => Speed::Standard, language_model_core::Speed::Fast => Speed::Fast, } } } impl From for language_model_core::LanguageModelCompletionError { fn from(error: AnthropicError) -> Self { completion_error_from_anthropic(error, language_model_core::ANTHROPIC_PROVIDER_NAME) } } impl From for language_model_core::LanguageModelCompletionError { fn from(error: ApiError) -> Self { completion_error_from_anthropic_api(error, language_model_core::ANTHROPIC_PROVIDER_NAME) } } pub fn completion_error_from_anthropic( error: AnthropicError, provider: language_model_core::LanguageModelProviderName, ) -> language_model_core::LanguageModelCompletionError { use language_model_core::LanguageModelCompletionError as Error; match error { AnthropicError::SerializeRequest(error) => Error::SerializeRequest { provider, error }, AnthropicError::BuildRequestBody(error) => Error::BuildRequestBody { provider, error }, AnthropicError::HttpSend { host, error } => Error::HttpSend { provider, host, error, }, AnthropicError::DeserializeResponse(error) => { Error::DeserializeResponse { provider, error } } AnthropicError::ReadResponse(error) => Error::ApiReadResponseError { provider, error }, AnthropicError::HttpResponseError { status_code, message, } => Error::from_http_status(provider, status_code, message, None), AnthropicError::RateLimit { status, retry_after, } => { let message = format!("{provider}'s API rate limit exceeded"); Error::from_provider_response( provider, Some(status), None, message, Some(retry_after), language_model_core::ProviderErrorCategory::RateLimit, ) } AnthropicError::ServerOverloaded { status, retry_after, } => { let message = format!("{provider}'s API servers are overloaded right now"); Error::from_provider_response( provider, Some(status), None, message, retry_after, language_model_core::ProviderErrorCategory::Overloaded, ) } AnthropicError::ApiError { status, error } => { completion_error_from_anthropic_api_with_status(error, provider, status) } } } pub fn completion_error_from_anthropic_api( error: ApiError, provider: language_model_core::LanguageModelProviderName, ) -> language_model_core::LanguageModelCompletionError { completion_error_from_anthropic_api_with_status(error, provider, None) } fn completion_error_from_anthropic_api_with_status( error: ApiError, provider: language_model_core::LanguageModelProviderName, status: Option, ) -> language_model_core::LanguageModelCompletionError { use ApiErrorCode::*; use language_model_core::LanguageModelCompletionError as Error; use language_model_core::ProviderErrorCategory; let category = match error.code() { Some(InvalidRequestError) => { if let Some(tokens) = parse_prompt_too_long(&error.message) { ProviderErrorCategory::PromptTooLarge { tokens: Some(tokens), } } else if error .message .starts_with("Your credit balance is too low to access the Anthropic API.") { // Anthropic sends credit exhaustion as invalid_request_error rather than // the billing_error documented at https://platform.claude.com/docs/en/api/errors. ProviderErrorCategory::PaymentRequired } else { ProviderErrorCategory::InvalidRequest } } Some(AuthenticationError) => ProviderErrorCategory::Authentication, Some(BillingError) => ProviderErrorCategory::PaymentRequired, Some(PermissionError) => ProviderErrorCategory::Permission, Some(NotFoundError) => ProviderErrorCategory::EndpointNotFound, Some(ConflictError) => ProviderErrorCategory::Conflict, Some(RequestTooLarge) => ProviderErrorCategory::PromptTooLarge { tokens: parse_prompt_too_long(&error.message), }, Some(RateLimitError) => ProviderErrorCategory::RateLimit, Some(TimeoutError) => ProviderErrorCategory::Timeout, Some(ApiError) => ProviderErrorCategory::InternalServer, Some(OverloadedError) => ProviderErrorCategory::Overloaded, None => ProviderErrorCategory::Other, }; Error::from_provider_response( provider, status, Some(error.error_type), error.message, None, category, ) } #[cfg(test)] mod tests { use super::*; use http_client::FakeHttpClient; #[test] fn count_input_tokens_preserves_input_without_generation_fields() { let request: Request = serde_json::from_value(serde_json::json!({ "model": "claude-sonnet-4-6", "max_tokens": 1234, "temperature": 0.5, "system": "Be precise", "messages": [{"role": "user", "content": [ {"type": "text", "text": "Describe this"}, {"type": "image", "source": { "type": "base64", "media_type": "image/png", "data": "aW1hZ2U=" }} ]}], "tools": [{"name": "look", "description": "Look up data", "input_schema": {"type": "object"}}], "tool_choice": {"type": "auto"}, "context_management": {"edits": [{"type": "compact_20260112"}]} })).expect("generation request"); let generation = serde_json::to_value(&request).expect("generation payload"); assert_eq!(generation["max_tokens"], 1234); assert_eq!(generation["temperature"], 0.5); let client = FakeHttpClient::create(move |mut request| async move { assert_eq!(request.method(), Method::POST); assert_eq!( request.uri(), "https://api.anthropic.com/v1/messages/count_tokens" ); assert!( request .headers() .get("X-Api-Key") .is_some_and(|value| value == "test-key") ); assert_eq!(request.headers()["Anthropic-Version"], "2023-06-01"); assert_eq!(request.headers()["Anthropic-Beta"], "test-beta"); let mut body = String::new(); request.body_mut().read_to_string(&mut body).await?; assert_eq!( serde_json::from_str::(&body)?, serde_json::json!({ "model": "claude-sonnet-4-6", "system": "Be precise", "messages": [{"role": "user", "content": [ {"type": "text", "text": "Describe this"}, {"type": "image", "source": { "type": "base64", "media_type": "image/png", "data": "aW1hZ2U=" }} ]}], "tools": [{"name": "look", "description": "Look up data", "input_schema": {"type": "object"}}], "tool_choice": {"type": "auto"}, "context_management": {"edits": [{"type": "compact_20260112"}]} }) ); Ok(http::Response::builder() .status(200) .body(AsyncBody::from(r#"{"input_tokens":321}"#))?) }); let count = futures::executor::block_on(count_input_tokens( client.as_ref(), ANTHROPIC_API_URL, " test-key ", request.into_count_tokens_request(), Some("test-beta".into()), &CustomHeaders::default(), )) .expect("count succeeds"); assert_eq!(count, 321); } #[test] fn count_input_tokens_preserves_typed_errors() { for (status, body) in [ ( 401, r#"{"type":"error","error":{"type":"authentication_error","message":"invalid x-api-key"}}"#, ), (200, "{}"), ] { let client = FakeHttpClient::create(move |_| async move { Ok(http::Response::builder() .status(status) .body(AsyncBody::from(body))?) }); let request: Request = serde_json::from_value(serde_json::json!({ "model": "claude-sonnet-4-6", "max_tokens": 100, "messages": [] })) .expect("request"); let error = futures::executor::block_on(count_input_tokens( client.as_ref(), ANTHROPIC_API_URL, "test-key", request.into_count_tokens_request(), None, &CustomHeaders::default(), )) .expect_err("count fails"); if status == 401 { assert!(matches!( error, AnthropicError::ApiError { status: Some(StatusCode::UNAUTHORIZED), .. } )); } else { assert!(matches!(error, AnthropicError::DeserializeResponse(_))); } } } #[test] fn list_models_preserves_anthropic_api_errors() { let client = FakeHttpClient::create(|_| async move { Ok(http::Response::builder() .status(StatusCode::UNAUTHORIZED) .body(AsyncBody::from( r#"{"type":"error","error":{"type":"authentication_error","message":"invalid x-api-key"},"request_id":"request-id"}"#, )) .expect("valid response")) }); let error = futures::executor::block_on(list_models( client.as_ref(), ANTHROPIC_API_URL, "invalid-key", &CustomHeaders::default(), )) .expect_err("authentication should fail"); assert!(matches!( error, AnthropicError::ApiError { status: Some(StatusCode::UNAUTHORIZED), error: ApiError { error_type, message, }, } if error_type == "authentication_error" && message == "invalid x-api-key" )); } #[test] fn list_models_preserves_anthropic_http_send_hostname() { let client = FakeHttpClient::create(|_| async move { Err(anyhow::anyhow!("DNS lookup failed")) }); let error = futures::executor::block_on(list_models( client.as_ref(), ANTHROPIC_API_URL, "test-key", &CustomHeaders::default(), )) .expect_err("request should fail"); let completion_error: language_model_core::LanguageModelCompletionError = error.into(); assert!(matches!( completion_error, language_model_core::LanguageModelCompletionError::HttpSend { host, error, .. } if host == "api.anthropic.com" && error.to_string() == "DNS lookup failed" )); } #[test] fn list_models_maps_anthropic_billing_errors_to_payment_required() { let client = FakeHttpClient::create(|_| async move { Ok(http::Response::builder() .status(StatusCode::PAYMENT_REQUIRED) .body(AsyncBody::from( r#"{"type":"error","error":{"type":"billing_error","message":"There is a problem with your billing information."},"request_id":"request-id"}"#, )) .expect("valid response")) }); let error = futures::executor::block_on(list_models( client.as_ref(), ANTHROPIC_API_URL, "test-key", &CustomHeaders::default(), )) .expect_err("billing error should fail"); let completion_error: language_model_core::LanguageModelCompletionError = error.into(); assert!(matches!( completion_error, language_model_core::LanguageModelCompletionError::ProviderRejection { category: language_model_core::ProviderErrorCategory::PaymentRequired, .. } )); } #[test] fn request_too_large_preserves_reported_token_count() { let error = completion_error_from_anthropic_api( ApiError { error_type: "request_too_large".to_string(), message: "prompt is too long: 1500000 tokens".to_string(), }, language_model_core::ANTHROPIC_PROVIDER_NAME, ); assert!(matches!( error, language_model_core::LanguageModelCompletionError::ProviderRejection { category: language_model_core::ProviderErrorCategory::PromptTooLarge { tokens: Some(1_500_000), }, .. } )); } #[test] fn list_models_preserves_anthropic_conflict_errors() { let client = FakeHttpClient::create(|_| async move { Ok(http::Response::builder() .status(StatusCode::CONFLICT) .body(AsyncBody::from( r#"{"type":"error","error":{"type":"conflict_error","message":"The resource was modified concurrently."},"request_id":"request-id"}"#, )) .expect("valid response")) }); let error = futures::executor::block_on(list_models( client.as_ref(), ANTHROPIC_API_URL, "test-key", &CustomHeaders::default(), )) .expect_err("conflict should fail"); let completion_error: language_model_core::LanguageModelCompletionError = error.into(); assert!(matches!( completion_error, language_model_core::LanguageModelCompletionError::ProviderRejection { provider, status: Some(StatusCode::CONFLICT), code: Some(code), message, retry_after: None, category: language_model_core::ProviderErrorCategory::Conflict, } if provider == language_model_core::ANTHROPIC_PROVIDER_NAME && code == "conflict_error" && message == "The resource was modified concurrently." )); } #[test] fn list_models_maps_anthropic_timeout_errors_to_gateway_timeout() { let client = FakeHttpClient::create(|_| async move { Ok(http::Response::builder() .status(StatusCode::GATEWAY_TIMEOUT) .body(AsyncBody::from( r#"{"type":"error","error":{"type":"timeout_error","message":"The request timed out."},"request_id":"request-id"}"#, )) .expect("valid response")) }); let error = futures::executor::block_on(list_models( client.as_ref(), ANTHROPIC_API_URL, "test-key", &CustomHeaders::default(), )) .expect_err("timeout should fail"); let completion_error: language_model_core::LanguageModelCompletionError = error.into(); assert!(matches!( completion_error, language_model_core::LanguageModelCompletionError::ProviderRejection { provider, message, status: Some(StatusCode::GATEWAY_TIMEOUT), code: Some(code), retry_after: None, category: language_model_core::ProviderErrorCategory::Timeout, } if provider == language_model_core::ANTHROPIC_PROVIDER_NAME && code == "timeout_error" && message == "The request timed out." )); } 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), } } #[test] fn api_list_model_entry_filters_null_token_limits() { let entries: Vec = serde_json::from_str( r#"[ { "id": "valid", "display_name": "Valid", "max_input_tokens": 200000, "max_tokens": 64000 }, { "id": "null-input", "display_name": "Null Input", "max_input_tokens": null, "max_tokens": 64000 }, { "id": "null-output", "display_name": "Null Output", "max_input_tokens": 200000, "max_tokens": null }, { "id": "null-both", "display_name": "Null Both", "max_input_tokens": null, "max_tokens": null } ]"#, ) .expect("entries should deserialize"); let entries = entries .into_iter() .filter_map(ApiListModelEntry::into_listed) .collect::>(); assert_eq!(entries.len(), 1); assert_eq!(entries[0].id, "valid"); } #[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 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_enables_fast_mode_and_compaction_for_supported_opus_models() { for model_id in ["claude-opus-5-5", "claude-opus-5", "claude-opus-4-8"] { let model = Model::from_listed(listed_entry(model_id, ModelCapabilities::default())); assert!(model.supports_speed); let beta_headers = model .beta_headers() .expect("model should have beta headers"); assert!(beta_headers.contains(FAST_MODE_BETA_HEADER)); assert!(beta_headers.contains(COMPACTION_BETA_HEADER)); } } #[test] fn from_listed_enables_compaction_and_binding_controls_for_fable_5_1() { let model = Model::from_listed(listed_entry( "claude-fable-5-1", ModelCapabilities::default(), )); let beta_headers = model .beta_headers() .expect("model should have beta headers"); assert!(beta_headers.contains(COMPACTION_BETA_HEADER)); assert!(beta_headers.contains(THINKING_BINDING_CONTROLS_BETA_HEADER)); // Mythos 5.1 supports compaction but doesn't run the prefix-binding // check, so it must not get the binding-controls header. let model = Model::from_listed(listed_entry( "claude-mythos-5-1", ModelCapabilities::default(), )); let beta_headers = model .beta_headers() .expect("model should have beta headers"); assert!(beta_headers.contains(COMPACTION_BETA_HEADER)); assert!(!beta_headers.contains(THINKING_BINDING_CONTROLS_BETA_HEADER)); } #[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] fn test_match_window_exceeded() { let error = ApiError { error_type: "invalid_request_error".to_string(), message: "prompt is too long: 220000 tokens > 200000".to_string(), }; assert_eq!(error.match_window_exceeded(), Some(220_000)); let error = ApiError { error_type: "invalid_request_error".to_string(), message: "prompt is too long: 1234953 tokens".to_string(), }; assert_eq!(error.match_window_exceeded(), Some(1234953)); let error = ApiError { error_type: "invalid_request_error".to_string(), message: "not a prompt length error".to_string(), }; assert_eq!(error.match_window_exceeded(), None); let error = ApiError { error_type: "rate_limit_error".to_string(), message: "prompt is too long: 12345 tokens".to_string(), }; assert_eq!(error.match_window_exceeded(), None); let error = ApiError { error_type: "invalid_request_error".to_string(), message: "prompt is too long: invalid tokens".to_string(), }; assert_eq!(error.match_window_exceeded(), None); }