use serde::{Deserialize, Serialize};
use crate::types::ModelId;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ModelInfo {
pub id: ModelId,
#[serde(default)]
pub display_name: String,
#[serde(default)]
pub created_at: String,
#[serde(rename = "type", default = "default_model_kind")]
pub kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_input_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capabilities: Option<ModelCapabilities>,
}
fn default_model_kind() -> String {
"model".to_owned()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct CapabilitySupport {
pub supported: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ModelCapabilities {
pub batch: CapabilitySupport,
pub citations: CapabilitySupport,
pub code_execution: CapabilitySupport,
pub context_management: ContextManagementCapability,
pub effort: EffortCapability,
pub image_input: CapabilitySupport,
pub pdf_input: CapabilitySupport,
pub structured_outputs: CapabilitySupport,
pub thinking: ThinkingCapability,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ContextManagementCapability {
pub supported: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub clear_thinking_20251015: Option<CapabilitySupport>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub clear_tool_uses_20250919: Option<CapabilitySupport>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compact_20260112: Option<CapabilitySupport>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct EffortCapability {
pub supported: bool,
pub low: CapabilitySupport,
pub medium: CapabilitySupport,
pub high: CapabilitySupport,
pub max: CapabilitySupport,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub xhigh: Option<CapabilitySupport>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ThinkingCapability {
pub supported: bool,
#[serde(default)]
pub types: ThinkingTypes,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ThinkingTypes {
pub adaptive: CapabilitySupport,
pub enabled: CapabilitySupport,
}
#[derive(Debug, Clone, Default, Serialize)]
#[non_exhaustive]
pub struct ListModelsParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub before_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub after_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
}
impl ListModelsParams {
#[must_use]
pub fn after_id(mut self, id: impl Into<String>) -> Self {
self.after_id = Some(id.into());
self
}
#[must_use]
pub fn before_id(mut self, id: impl Into<String>) -> Self {
self.before_id = Some(id.into());
self
}
#[must_use]
pub fn limit(mut self, limit: u32) -> Self {
self.limit = Some(limit);
self
}
}
#[cfg(feature = "async")]
#[cfg_attr(docsrs, doc(cfg(feature = "async")))]
pub use api::Models;
#[cfg(feature = "async")]
mod api {
use super::{ListModelsParams, ModelInfo};
use crate::client::Client;
use crate::error::Result;
use crate::pagination::Paginated;
pub struct Models<'a> {
client: &'a Client,
}
impl<'a> Models<'a> {
pub(crate) fn new(client: &'a Client) -> Self {
Self { client }
}
pub async fn list(&self, params: ListModelsParams) -> Result<Paginated<ModelInfo>> {
let params_ref = ¶ms;
self.client
.execute_with_retry(
|| {
self.client
.request_builder(reqwest::Method::GET, "/v1/models")
.query(params_ref)
},
&[],
)
.await
}
pub async fn list_all(&self) -> Result<Vec<ModelInfo>> {
let mut all = Vec::new();
let mut params = ListModelsParams::default();
loop {
let page = self.list(params.clone()).await?;
let next_cursor = page.next_after().map(str::to_owned);
all.extend(page.data);
match next_cursor {
Some(cursor) => params.after_id = Some(cursor),
None => break,
}
}
Ok(all)
}
pub async fn get(&self, id: impl AsRef<str>) -> Result<ModelInfo> {
let path = format!("/v1/models/{}", id.as_ref());
self.client
.execute_with_retry(
|| self.client.request_builder(reqwest::Method::GET, &path),
&[],
)
.await
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use serde_json::json;
#[test]
fn model_info_round_trips_with_known_fields() {
let raw = json!({
"type": "model",
"id": "claude-opus-4-7",
"display_name": "Claude Opus 4.7",
"created_at": "2025-12-01T00:00:00Z"
});
let m: ModelInfo = serde_json::from_value(raw.clone()).unwrap();
assert_eq!(m.id, ModelId::OPUS_4_7);
assert_eq!(m.display_name, "Claude Opus 4.7");
assert_eq!(m.created_at, "2025-12-01T00:00:00Z");
assert_eq!(m.kind, "model");
let v = serde_json::to_value(&m).unwrap();
assert_eq!(v, raw);
}
#[test]
fn model_info_kind_defaults_to_model_when_missing() {
let raw = json!({"id": "claude-x", "display_name": "X", "created_at": "2025"});
let m: ModelInfo = serde_json::from_value(raw).unwrap();
assert_eq!(m.kind, "model");
}
#[test]
fn list_models_params_default_serializes_to_empty_object() {
let p = ListModelsParams::default();
assert_eq!(serde_json::to_value(&p).unwrap(), json!({}));
}
#[test]
fn list_models_params_builder_methods() {
let p = ListModelsParams::default().after_id("abc").limit(50);
assert_eq!(p.after_id.as_deref(), Some("abc"));
assert_eq!(p.limit, Some(50));
}
}
#[cfg(all(test, feature = "async"))]
mod api_tests {
use super::*;
use crate::client::Client;
use pretty_assertions::assert_eq;
use serde_json::json;
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn client_for(mock: &MockServer) -> Client {
Client::builder()
.api_key("sk-ant-test")
.base_url(mock.uri())
.build()
.unwrap()
}
fn page_body(ids: &[&str], has_more: bool) -> serde_json::Value {
let data: Vec<_> = ids
.iter()
.map(|id| {
json!({
"type": "model",
"id": id,
"display_name": id,
"created_at": "2025-01-01T00:00:00Z"
})
})
.collect();
json!({
"data": data,
"has_more": has_more,
"first_id": ids.first().unwrap_or(&""),
"last_id": ids.last().unwrap_or(&"")
})
}
#[tokio::test]
async fn list_returns_a_single_page() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/models"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(page_body(&["claude-opus-4-7", "claude-sonnet-4-6"], false)),
)
.mount(&mock)
.await;
let client = client_for(&mock);
let page = client
.models()
.list(ListModelsParams::default())
.await
.unwrap();
assert_eq!(page.data.len(), 2);
assert_eq!(page.data[0].id, ModelId::OPUS_4_7);
assert!(!page.has_more);
assert_eq!(page.next_after(), None);
}
#[tokio::test]
async fn list_passes_pagination_query_params() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/models"))
.and(query_param("after_id", "claude-x"))
.and(query_param("limit", "10"))
.respond_with(ResponseTemplate::new(200).set_body_json(page_body(&[], false)))
.mount(&mock)
.await;
let client = client_for(&mock);
let _ = client
.models()
.list(ListModelsParams::default().after_id("claude-x").limit(10))
.await
.unwrap();
}
#[tokio::test]
async fn list_all_pages_through_results_and_concatenates() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [
{"type": "model", "id": "claude-opus-4-7", "display_name": "O", "created_at": "x"},
{"type": "model", "id": "claude-sonnet-4-6", "display_name": "S", "created_at": "x"}
],
"has_more": true,
"first_id": "claude-opus-4-7",
"last_id": "claude-sonnet-4-6"
})))
.up_to_n_times(1)
.mount(&mock)
.await;
Mock::given(method("GET"))
.and(path("/v1/models"))
.and(query_param("after_id", "claude-sonnet-4-6"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"data": [
{"type": "model", "id": "claude-haiku-4-5-20251001", "display_name": "H", "created_at": "x"}
],
"has_more": false,
"first_id": "claude-haiku-4-5-20251001",
"last_id": "claude-haiku-4-5-20251001"
})))
.mount(&mock)
.await;
let client = client_for(&mock);
let all = client.models().list_all().await.unwrap();
assert_eq!(all.len(), 3);
assert_eq!(all[0].id, ModelId::OPUS_4_7);
assert_eq!(all[1].id, ModelId::SONNET_4_6);
assert_eq!(all[2].id, ModelId::HAIKU_4_5);
}
#[tokio::test]
async fn get_fetches_a_single_model_by_id() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/models/claude-opus-4-7"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"type": "model",
"id": "claude-opus-4-7",
"display_name": "Claude Opus 4.7",
"created_at": "2025-12-01T00:00:00Z"
})))
.mount(&mock)
.await;
let client = client_for(&mock);
let m = client.models().get("claude-opus-4-7").await.unwrap();
assert_eq!(m.id, ModelId::OPUS_4_7);
assert_eq!(m.display_name, "Claude Opus 4.7");
}
#[tokio::test]
async fn get_propagates_404_as_api_error() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/models/nope"))
.respond_with(ResponseTemplate::new(404).set_body_json(json!({
"type": "error",
"error": {"type": "not_found_error", "message": "no such model"}
})))
.mount(&mock)
.await;
let client = client_for(&mock);
let err = client.models().get("nope").await.unwrap_err();
assert_eq!(err.status(), Some(http::StatusCode::NOT_FOUND));
}
#[test]
fn capability_support_round_trips_minimal_payload() {
let raw = json!({"supported": true});
let cs: CapabilitySupport = serde_json::from_value(raw.clone()).unwrap();
assert!(cs.supported);
assert_eq!(serde_json::to_value(cs).unwrap(), raw);
}
#[test]
fn model_capabilities_decodes_full_real_world_response() {
let raw = json!({
"batch": {"supported": true},
"citations": {"supported": true},
"code_execution": {"supported": true},
"context_management": {
"clear_thinking_20251015": {"supported": true},
"clear_tool_uses_20250919": {"supported": true},
"compact_20260112": {"supported": true},
"supported": true
},
"effort": {
"high": {"supported": true},
"low": {"supported": true},
"max": {"supported": true},
"medium": {"supported": true},
"supported": true
},
"image_input": {"supported": true},
"pdf_input": {"supported": true},
"structured_outputs": {"supported": true},
"thinking": {
"supported": true,
"types": {
"adaptive": {"supported": true},
"enabled": {"supported": true}
}
}
});
let caps: ModelCapabilities = serde_json::from_value(raw).unwrap();
assert!(caps.batch.supported);
assert!(caps.context_management.supported);
assert_eq!(
caps.context_management
.clear_thinking_20251015
.map(|c| c.supported),
Some(true),
);
assert!(caps.effort.high.supported);
assert!(caps.effort.xhigh.is_none(), "xhigh absent on this model");
assert!(caps.thinking.types.adaptive.supported);
}
#[test]
fn model_capabilities_tolerates_optional_strategy_fields_missing() {
let raw = json!({
"batch": {"supported": false},
"citations": {"supported": false},
"code_execution": {"supported": false},
"context_management": {"supported": false},
"effort": {
"high": {"supported": false},
"low": {"supported": false},
"max": {"supported": false},
"medium": {"supported": false},
"supported": false
},
"image_input": {"supported": false},
"pdf_input": {"supported": false},
"structured_outputs": {"supported": false},
"thinking": {
"supported": false,
"types": {
"adaptive": {"supported": false},
"enabled": {"supported": false}
}
}
});
let caps: ModelCapabilities = serde_json::from_value(raw).unwrap();
assert!(caps.context_management.clear_thinking_20251015.is_none());
assert!(caps.context_management.clear_tool_uses_20250919.is_none());
assert!(caps.context_management.compact_20260112.is_none());
}
#[test]
fn effort_capability_decodes_xhigh_when_present() {
let raw = json!({
"supported": true,
"low": {"supported": true},
"medium": {"supported": true},
"high": {"supported": true},
"max": {"supported": true},
"xhigh": {"supported": true}
});
let e: EffortCapability = serde_json::from_value(raw).unwrap();
assert_eq!(e.xhigh.map(|c| c.supported), Some(true));
}
#[test]
fn model_info_with_capabilities_round_trips() {
let raw = json!({
"type": "model",
"id": "claude-sonnet-4-6",
"display_name": "Claude Sonnet 4.6",
"created_at": "2025-09-29T00:00:00Z",
"max_tokens": 64_000,
"max_input_tokens": 200_000,
"capabilities": {
"batch": {"supported": true},
"citations": {"supported": true},
"code_execution": {"supported": true},
"context_management": {"supported": true},
"effort": {
"high": {"supported": true},
"low": {"supported": true},
"max": {"supported": true},
"medium": {"supported": true},
"supported": true
},
"image_input": {"supported": true},
"pdf_input": {"supported": true},
"structured_outputs": {"supported": true},
"thinking": {
"supported": true,
"types": {
"adaptive": {"supported": true},
"enabled": {"supported": true}
}
}
}
});
let m: ModelInfo = serde_json::from_value(raw).unwrap();
let caps = m.capabilities.unwrap();
assert!(caps.thinking.supported);
assert_eq!(m.max_tokens, Some(64_000));
}
}