1use serde::{Deserialize, Serialize};
33
34use crate::types::ModelId;
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38#[non_exhaustive]
39pub struct ModelInfo {
40 pub id: ModelId,
42 #[serde(default)]
44 pub display_name: String,
45 #[serde(default)]
47 pub created_at: String,
48 #[serde(rename = "type", default = "default_model_kind")]
50 pub kind: String,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub max_tokens: Option<u64>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub max_input_tokens: Option<u64>,
58 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
76#[non_exhaustive]
77pub struct CapabilitySupport {
78 pub supported: bool,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
93#[non_exhaustive]
94pub struct ModelCapabilities {
95 pub batch: CapabilitySupport,
97 pub citations: CapabilitySupport,
99 pub code_execution: CapabilitySupport,
101 pub context_management: ContextManagementCapability,
104 pub effort: EffortCapability,
106 pub image_input: CapabilitySupport,
108 pub pdf_input: CapabilitySupport,
110 pub structured_outputs: CapabilitySupport,
112 pub thinking: ThinkingCapability,
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
123#[non_exhaustive]
124pub struct ContextManagementCapability {
125 pub supported: bool,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub clear_thinking_20251015: Option<CapabilitySupport>,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub clear_tool_uses_20250919: Option<CapabilitySupport>,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub compact_20260112: Option<CapabilitySupport>,
136}
137
138#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
140#[non_exhaustive]
141pub struct EffortCapability {
142 pub supported: bool,
144 pub low: CapabilitySupport,
146 pub medium: CapabilitySupport,
148 pub high: CapabilitySupport,
150 pub max: CapabilitySupport,
152 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub xhigh: Option<CapabilitySupport>,
155}
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
159#[non_exhaustive]
160pub struct ThinkingCapability {
161 pub supported: bool,
163 #[serde(default)]
165 pub types: ThinkingTypes,
166}
167
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
170#[non_exhaustive]
171pub struct ThinkingTypes {
172 pub adaptive: CapabilitySupport,
174 pub enabled: CapabilitySupport,
176}
177
178#[derive(Debug, Clone, Default, Serialize)]
180#[non_exhaustive]
181pub struct ListModelsParams {
182 #[serde(skip_serializing_if = "Option::is_none")]
184 pub before_id: Option<String>,
185 #[serde(skip_serializing_if = "Option::is_none")]
187 pub after_id: Option<String>,
188 #[serde(skip_serializing_if = "Option::is_none")]
190 pub limit: Option<u32>,
191}
192
193impl ListModelsParams {
194 #[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 #[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 #[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 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 pub async fn list(&self, params: ListModelsParams) -> Result<Paginated<ModelInfo>> {
241 let params_ref = ¶ms;
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 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 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 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 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 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 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}