Skip to main content

claude_api/models/
mod.rs

1//! The Models API.
2//!
3//! Discover what models are available to your API key, with their
4//! capability matrix and per-model token limits.
5//!
6//! # Endpoints
7//!
8//! | Method | Path | Function |
9//! |---|---|---|
10//! | `GET` | `/v1/models` | [`Models::list`] (paginated) |
11//! | `GET` | `/v1/models` | [`Models::list_all`] (auto-paginates) |
12//! | `GET` | `/v1/models/{id}` | [`Models::get`] |
13//!
14//! # Quick start
15//!
16//! ```no_run
17//! use claude_api::{Client, models::ListModelsParams};
18//! # async fn run() -> Result<(), claude_api::Error> {
19//! let client = Client::new("sk-ant-...");
20//!
21//! // Iterate the full set transparently:
22//! for model in client.models().list_all().await? {
23//!     println!("{}: {}", model.id.as_str(), model.display_name);
24//! }
25//!
26//! // Or fetch one by ID:
27//! let m = client.models().get("claude-sonnet-4-6").await?;
28//! println!("{} (max input: {:?})", m.display_name, m.max_input_tokens);
29//! # Ok(()) }
30//! ```
31
32use serde::{Deserialize, Serialize};
33
34use crate::types::ModelId;
35
36/// Metadata for a single model returned by the Models API.
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38#[non_exhaustive]
39pub struct ModelInfo {
40    /// Stable model identifier (e.g. `claude-opus-4-7`).
41    pub id: ModelId,
42    /// Human-readable display name.
43    #[serde(default)]
44    pub display_name: String,
45    /// Creation timestamp (ISO-8601 string).
46    #[serde(default)]
47    pub created_at: String,
48    /// Wire `type` discriminant; always `"model"`.
49    #[serde(rename = "type", default = "default_model_kind")]
50    pub kind: String,
51    /// Maximum total tokens (input + output) the model can produce in
52    /// a single response.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub max_tokens: Option<u64>,
55    /// Maximum input tokens the model can accept.
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub max_input_tokens: Option<u64>,
58    /// Capability matrix: which features (citations, code execution,
59    /// thinking, image input, etc.) the model supports and at what
60    /// level.
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub capabilities: Option<ModelCapabilities>,
63}
64
65fn default_model_kind() -> String {
66    "model".to_owned()
67}
68
69/// Whether a single capability is supported by the model.
70///
71/// The atomic unit of [`ModelCapabilities`]. New capability flags
72/// added by Anthropic show up as new fields on the wider struct;
73/// each is shaped as a `CapabilitySupport` so the whole tree
74/// destructures uniformly.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
76#[non_exhaustive]
77pub struct CapabilitySupport {
78    /// `true` if the model supports the capability this entry
79    /// describes.
80    pub supported: bool,
81}
82
83/// Per-model feature matrix returned on every [`ModelInfo`].
84///
85/// Each leaf is a [`CapabilitySupport`] (a single boolean). The
86/// nested capabilities ([`ContextManagementCapability`],
87/// [`EffortCapability`], [`ThinkingCapability`]) carry both a
88/// top-level `supported` flag and a per-variant breakdown.
89///
90/// `#[non_exhaustive]` -- new capability fields appear over time;
91/// callers should pattern-match conservatively.
92#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
93#[non_exhaustive]
94pub struct ModelCapabilities {
95    /// Batch API support.
96    pub batch: CapabilitySupport,
97    /// Citation generation in responses.
98    pub citations: CapabilitySupport,
99    /// Code-execution server tool.
100    pub code_execution: CapabilitySupport,
101    /// Context-management strategies (`compact`, `clear_thinking`,
102    /// etc.).
103    pub context_management: ContextManagementCapability,
104    /// `effort` (`reasoning_effort`) levels.
105    pub effort: EffortCapability,
106    /// Image content blocks on requests.
107    pub image_input: CapabilitySupport,
108    /// PDF document blocks on requests.
109    pub pdf_input: CapabilitySupport,
110    /// Structured-output / strict-schema mode.
111    pub structured_outputs: CapabilitySupport,
112    /// Extended thinking (`thinking` block) and its type variants.
113    pub thinking: ThinkingCapability,
114}
115
116/// Context-management support and strategies.
117///
118/// `supported` is the top-level gate. The named-strategy fields
119/// (one per dated strategy ID) are `Option<CapabilitySupport>` since
120/// not every model exposes every strategy and Anthropic ships new
121/// strategies with date-suffixed names.
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
123#[non_exhaustive]
124pub struct ContextManagementCapability {
125    /// Whether any context-management strategy is supported.
126    pub supported: bool,
127    /// `clear_thinking_20251015` strategy.
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub clear_thinking_20251015: Option<CapabilitySupport>,
130    /// `clear_tool_uses_20250919` strategy.
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub clear_tool_uses_20250919: Option<CapabilitySupport>,
133    /// `compact_20260112` strategy.
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub compact_20260112: Option<CapabilitySupport>,
136}
137
138/// `effort` (`reasoning_effort`) capability + per-level support.
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
140#[non_exhaustive]
141pub struct EffortCapability {
142    /// Whether `effort` is supported at all.
143    pub supported: bool,
144    /// `low` effort.
145    pub low: CapabilitySupport,
146    /// `medium` effort.
147    pub medium: CapabilitySupport,
148    /// `high` effort.
149    pub high: CapabilitySupport,
150    /// `max` effort.
151    pub max: CapabilitySupport,
152    /// `xhigh` effort (only on some models). Optional in the spec.
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub xhigh: Option<CapabilitySupport>,
155}
156
157/// Extended-thinking capability + type variants.
158#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
159#[non_exhaustive]
160pub struct ThinkingCapability {
161    /// Whether thinking is supported at all.
162    pub supported: bool,
163    /// Per-`type` thinking-mode breakdown.
164    #[serde(default)]
165    pub types: ThinkingTypes,
166}
167
168/// Thinking-mode variants.
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
170#[non_exhaustive]
171pub struct ThinkingTypes {
172    /// `type: "adaptive"` (auto-decide thinking).
173    pub adaptive: CapabilitySupport,
174    /// `type: "enabled"` (always think).
175    pub enabled: CapabilitySupport,
176}
177
178/// Query parameters for `GET /v1/models`.
179#[derive(Debug, Clone, Default, Serialize)]
180#[non_exhaustive]
181pub struct ListModelsParams {
182    /// Cursor for backward pagination: page items before this `id`.
183    #[serde(skip_serializing_if = "Option::is_none")]
184    pub before_id: Option<String>,
185    /// Cursor for forward pagination: page items after this `id`.
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub after_id: Option<String>,
188    /// Page size (server-defaulted if omitted; 1..=1000).
189    #[serde(skip_serializing_if = "Option::is_none")]
190    pub limit: Option<u32>,
191}
192
193impl ListModelsParams {
194    /// Set the `after_id` cursor (forward paging).
195    #[must_use]
196    pub fn after_id(mut self, id: impl Into<String>) -> Self {
197        self.after_id = Some(id.into());
198        self
199    }
200
201    /// Set the `before_id` cursor (backward paging).
202    #[must_use]
203    pub fn before_id(mut self, id: impl Into<String>) -> Self {
204        self.before_id = Some(id.into());
205        self
206    }
207
208    /// Set the page size.
209    #[must_use]
210    pub fn limit(mut self, limit: u32) -> Self {
211        self.limit = Some(limit);
212        self
213    }
214}
215
216#[cfg(feature = "async")]
217#[cfg_attr(docsrs, doc(cfg(feature = "async")))]
218pub use api::Models;
219
220#[cfg(feature = "async")]
221mod api {
222    use super::{ListModelsParams, ModelInfo};
223    use crate::client::Client;
224    use crate::error::Result;
225    use crate::pagination::Paginated;
226
227    /// Namespace handle for the Models API.
228    ///
229    /// Obtained via [`Client::models`](crate::Client::models).
230    pub struct Models<'a> {
231        client: &'a Client,
232    }
233
234    impl<'a> Models<'a> {
235        pub(crate) fn new(client: &'a Client) -> Self {
236            Self { client }
237        }
238
239        /// Fetch one page of models.
240        pub async fn list(&self, params: ListModelsParams) -> Result<Paginated<ModelInfo>> {
241            let params_ref = &params;
242            self.client
243                .execute_with_retry(
244                    || {
245                        self.client
246                            .request_builder(reqwest::Method::GET, "/v1/models")
247                            .query(params_ref)
248                    },
249                    &[],
250                )
251                .await
252        }
253
254        /// Fetch all models, transparently paging until exhausted.
255        ///
256        /// Returns the full list as a single `Vec`. Use [`Self::list`] if
257        /// you need to control paging yourself (e.g. for backpressure).
258        pub async fn list_all(&self) -> Result<Vec<ModelInfo>> {
259            let mut all = Vec::new();
260            let mut params = ListModelsParams::default();
261            loop {
262                let page = self.list(params.clone()).await?;
263                let next_cursor = page.next_after().map(str::to_owned);
264                all.extend(page.data);
265                match next_cursor {
266                    Some(cursor) => params.after_id = Some(cursor),
267                    None => break,
268                }
269            }
270            Ok(all)
271        }
272
273        /// Fetch metadata for a single model by ID.
274        pub async fn get(&self, id: impl AsRef<str>) -> Result<ModelInfo> {
275            let path = format!("/v1/models/{}", id.as_ref());
276            self.client
277                .execute_with_retry(
278                    || self.client.request_builder(reqwest::Method::GET, &path),
279                    &[],
280                )
281                .await
282        }
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use pretty_assertions::assert_eq;
290    use serde_json::json;
291
292    #[test]
293    fn model_info_round_trips_with_known_fields() {
294        let raw = json!({
295            "type": "model",
296            "id": "claude-opus-4-7",
297            "display_name": "Claude Opus 4.7",
298            "created_at": "2025-12-01T00:00:00Z"
299        });
300        let m: ModelInfo = serde_json::from_value(raw.clone()).unwrap();
301        assert_eq!(m.id, ModelId::OPUS_4_7);
302        assert_eq!(m.display_name, "Claude Opus 4.7");
303        assert_eq!(m.created_at, "2025-12-01T00:00:00Z");
304        assert_eq!(m.kind, "model");
305        let v = serde_json::to_value(&m).unwrap();
306        assert_eq!(v, raw);
307    }
308
309    #[test]
310    fn model_info_kind_defaults_to_model_when_missing() {
311        let raw = json!({"id": "claude-x", "display_name": "X", "created_at": "2025"});
312        let m: ModelInfo = serde_json::from_value(raw).unwrap();
313        assert_eq!(m.kind, "model");
314    }
315
316    #[test]
317    fn list_models_params_default_serializes_to_empty_object() {
318        let p = ListModelsParams::default();
319        assert_eq!(serde_json::to_value(&p).unwrap(), json!({}));
320    }
321
322    #[test]
323    fn list_models_params_builder_methods() {
324        let p = ListModelsParams::default().after_id("abc").limit(50);
325        assert_eq!(p.after_id.as_deref(), Some("abc"));
326        assert_eq!(p.limit, Some(50));
327    }
328}
329
330#[cfg(all(test, feature = "async"))]
331mod api_tests {
332    use super::*;
333    use crate::client::Client;
334    use pretty_assertions::assert_eq;
335    use serde_json::json;
336    use wiremock::matchers::{method, path, query_param};
337    use wiremock::{Mock, MockServer, ResponseTemplate};
338
339    fn client_for(mock: &MockServer) -> Client {
340        Client::builder()
341            .api_key("sk-ant-test")
342            .base_url(mock.uri())
343            .build()
344            .unwrap()
345    }
346
347    fn page_body(ids: &[&str], has_more: bool) -> serde_json::Value {
348        let data: Vec<_> = ids
349            .iter()
350            .map(|id| {
351                json!({
352                    "type": "model",
353                    "id": id,
354                    "display_name": id,
355                    "created_at": "2025-01-01T00:00:00Z"
356                })
357            })
358            .collect();
359        json!({
360            "data": data,
361            "has_more": has_more,
362            "first_id": ids.first().unwrap_or(&""),
363            "last_id": ids.last().unwrap_or(&"")
364        })
365    }
366
367    #[tokio::test]
368    async fn list_returns_a_single_page() {
369        let mock = MockServer::start().await;
370        Mock::given(method("GET"))
371            .and(path("/v1/models"))
372            .respond_with(
373                ResponseTemplate::new(200)
374                    .set_body_json(page_body(&["claude-opus-4-7", "claude-sonnet-4-6"], false)),
375            )
376            .mount(&mock)
377            .await;
378
379        let client = client_for(&mock);
380        let page = client
381            .models()
382            .list(ListModelsParams::default())
383            .await
384            .unwrap();
385        assert_eq!(page.data.len(), 2);
386        assert_eq!(page.data[0].id, ModelId::OPUS_4_7);
387        assert!(!page.has_more);
388        assert_eq!(page.next_after(), None);
389    }
390
391    #[tokio::test]
392    async fn list_passes_pagination_query_params() {
393        let mock = MockServer::start().await;
394        Mock::given(method("GET"))
395            .and(path("/v1/models"))
396            .and(query_param("after_id", "claude-x"))
397            .and(query_param("limit", "10"))
398            .respond_with(ResponseTemplate::new(200).set_body_json(page_body(&[], false)))
399            .mount(&mock)
400            .await;
401
402        let client = client_for(&mock);
403        let _ = client
404            .models()
405            .list(ListModelsParams::default().after_id("claude-x").limit(10))
406            .await
407            .unwrap();
408    }
409
410    #[tokio::test]
411    async fn list_all_pages_through_results_and_concatenates() {
412        let mock = MockServer::start().await;
413        // First page: has_more = true, last_id = "claude-sonnet-4-6"
414        Mock::given(method("GET"))
415            .and(path("/v1/models"))
416            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
417                "data": [
418                    {"type": "model", "id": "claude-opus-4-7", "display_name": "O", "created_at": "x"},
419                    {"type": "model", "id": "claude-sonnet-4-6", "display_name": "S", "created_at": "x"}
420                ],
421                "has_more": true,
422                "first_id": "claude-opus-4-7",
423                "last_id": "claude-sonnet-4-6"
424            })))
425            .up_to_n_times(1)
426            .mount(&mock)
427            .await;
428        // Second page: has_more = false. Wiremock must see after_id=claude-sonnet-4-6.
429        Mock::given(method("GET"))
430            .and(path("/v1/models"))
431            .and(query_param("after_id", "claude-sonnet-4-6"))
432            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
433                "data": [
434                    {"type": "model", "id": "claude-haiku-4-5-20251001", "display_name": "H", "created_at": "x"}
435                ],
436                "has_more": false,
437                "first_id": "claude-haiku-4-5-20251001",
438                "last_id": "claude-haiku-4-5-20251001"
439            })))
440            .mount(&mock)
441            .await;
442
443        let client = client_for(&mock);
444        let all = client.models().list_all().await.unwrap();
445        assert_eq!(all.len(), 3);
446        assert_eq!(all[0].id, ModelId::OPUS_4_7);
447        assert_eq!(all[1].id, ModelId::SONNET_4_6);
448        assert_eq!(all[2].id, ModelId::HAIKU_4_5);
449    }
450
451    #[tokio::test]
452    async fn get_fetches_a_single_model_by_id() {
453        let mock = MockServer::start().await;
454        Mock::given(method("GET"))
455            .and(path("/v1/models/claude-opus-4-7"))
456            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
457                "type": "model",
458                "id": "claude-opus-4-7",
459                "display_name": "Claude Opus 4.7",
460                "created_at": "2025-12-01T00:00:00Z"
461            })))
462            .mount(&mock)
463            .await;
464
465        let client = client_for(&mock);
466        let m = client.models().get("claude-opus-4-7").await.unwrap();
467        assert_eq!(m.id, ModelId::OPUS_4_7);
468        assert_eq!(m.display_name, "Claude Opus 4.7");
469    }
470
471    #[tokio::test]
472    async fn get_propagates_404_as_api_error() {
473        let mock = MockServer::start().await;
474        Mock::given(method("GET"))
475            .and(path("/v1/models/nope"))
476            .respond_with(ResponseTemplate::new(404).set_body_json(json!({
477                "type": "error",
478                "error": {"type": "not_found_error", "message": "no such model"}
479            })))
480            .mount(&mock)
481            .await;
482
483        let client = client_for(&mock);
484        let err = client.models().get("nope").await.unwrap_err();
485        assert_eq!(err.status(), Some(http::StatusCode::NOT_FOUND));
486    }
487
488    #[test]
489    fn capability_support_round_trips_minimal_payload() {
490        let raw = json!({"supported": true});
491        let cs: CapabilitySupport = serde_json::from_value(raw.clone()).unwrap();
492        assert!(cs.supported);
493        assert_eq!(serde_json::to_value(cs).unwrap(), raw);
494    }
495
496    #[test]
497    fn model_capabilities_decodes_full_real_world_response() {
498        // Lifted verbatim from the live cassette
499        // (live_models_get_sonnet_4_6.jsonl). Pin against drift.
500        let raw = json!({
501            "batch": {"supported": true},
502            "citations": {"supported": true},
503            "code_execution": {"supported": true},
504            "context_management": {
505                "clear_thinking_20251015": {"supported": true},
506                "clear_tool_uses_20250919": {"supported": true},
507                "compact_20260112": {"supported": true},
508                "supported": true
509            },
510            "effort": {
511                "high": {"supported": true},
512                "low": {"supported": true},
513                "max": {"supported": true},
514                "medium": {"supported": true},
515                "supported": true
516            },
517            "image_input": {"supported": true},
518            "pdf_input": {"supported": true},
519            "structured_outputs": {"supported": true},
520            "thinking": {
521                "supported": true,
522                "types": {
523                    "adaptive": {"supported": true},
524                    "enabled": {"supported": true}
525                }
526            }
527        });
528        let caps: ModelCapabilities = serde_json::from_value(raw).unwrap();
529        assert!(caps.batch.supported);
530        assert!(caps.context_management.supported);
531        assert_eq!(
532            caps.context_management
533                .clear_thinking_20251015
534                .map(|c| c.supported),
535            Some(true),
536        );
537        assert!(caps.effort.high.supported);
538        assert!(caps.effort.xhigh.is_none(), "xhigh absent on this model");
539        assert!(caps.thinking.types.adaptive.supported);
540    }
541
542    #[test]
543    fn model_capabilities_tolerates_optional_strategy_fields_missing() {
544        // A model that doesn't expose the dated context-management
545        // strategy fields should still decode.
546        let raw = json!({
547            "batch": {"supported": false},
548            "citations": {"supported": false},
549            "code_execution": {"supported": false},
550            "context_management": {"supported": false},
551            "effort": {
552                "high": {"supported": false},
553                "low": {"supported": false},
554                "max": {"supported": false},
555                "medium": {"supported": false},
556                "supported": false
557            },
558            "image_input": {"supported": false},
559            "pdf_input": {"supported": false},
560            "structured_outputs": {"supported": false},
561            "thinking": {
562                "supported": false,
563                "types": {
564                    "adaptive": {"supported": false},
565                    "enabled": {"supported": false}
566                }
567            }
568        });
569        let caps: ModelCapabilities = serde_json::from_value(raw).unwrap();
570        assert!(caps.context_management.clear_thinking_20251015.is_none());
571        assert!(caps.context_management.clear_tool_uses_20250919.is_none());
572        assert!(caps.context_management.compact_20260112.is_none());
573    }
574
575    #[test]
576    fn effort_capability_decodes_xhigh_when_present() {
577        let raw = json!({
578            "supported": true,
579            "low": {"supported": true},
580            "medium": {"supported": true},
581            "high": {"supported": true},
582            "max": {"supported": true},
583            "xhigh": {"supported": true}
584        });
585        let e: EffortCapability = serde_json::from_value(raw).unwrap();
586        assert_eq!(e.xhigh.map(|c| c.supported), Some(true));
587    }
588
589    #[test]
590    fn model_info_with_capabilities_round_trips() {
591        let raw = json!({
592            "type": "model",
593            "id": "claude-sonnet-4-6",
594            "display_name": "Claude Sonnet 4.6",
595            "created_at": "2025-09-29T00:00:00Z",
596            "max_tokens": 64_000,
597            "max_input_tokens": 200_000,
598            "capabilities": {
599                "batch": {"supported": true},
600                "citations": {"supported": true},
601                "code_execution": {"supported": true},
602                "context_management": {"supported": true},
603                "effort": {
604                    "high": {"supported": true},
605                    "low": {"supported": true},
606                    "max": {"supported": true},
607                    "medium": {"supported": true},
608                    "supported": true
609                },
610                "image_input": {"supported": true},
611                "pdf_input": {"supported": true},
612                "structured_outputs": {"supported": true},
613                "thinking": {
614                    "supported": true,
615                    "types": {
616                        "adaptive": {"supported": true},
617                        "enabled": {"supported": true}
618                    }
619                }
620            }
621        });
622        let m: ModelInfo = serde_json::from_value(raw).unwrap();
623        let caps = m.capabilities.unwrap();
624        assert!(caps.thinking.supported);
625        assert_eq!(m.max_tokens, Some(64_000));
626    }
627}