use crate::{
client::ModelLister,
http_client::HttpClientExt,
model::{Model, ModelList, ModelListingError},
providers::{anthropic::Client, internal},
wasm_compat::{WasmCompatSend, WasmCompatSync},
};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct ListModelsResponse {
data: Vec<ListModelEntry>,
has_more: bool,
last_id: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ListModelEntry {
id: String,
display_name: String,
}
impl From<ListModelEntry> for Model {
fn from(value: ListModelEntry) -> Self {
Model::new(value.id, value.display_name)
}
}
#[derive(Clone)]
pub struct AnthropicModelLister<H = reqwest::Client> {
client: Client<H>,
}
impl<H> ModelLister<H> for AnthropicModelLister<H>
where
H: HttpClientExt + WasmCompatSend + WasmCompatSync + 'static,
{
type Client = Client<H>;
fn new(client: Self::Client) -> Self {
Self { client }
}
async fn list_all(&self) -> Result<ModelList, ModelListingError> {
internal::model_listing::paginate_models(
&self.client,
"Anthropic",
|cursor| match cursor {
Some(cursor) => {
internal::model_listing::with_query_pairs("/v1/models", &[("after_id", cursor)])
}
None => "/v1/models".to_string(),
},
parse_page,
)
.await
}
}
fn parse_page(
body: &[u8],
path: &str,
) -> Result<internal::model_listing::ListingPage, ModelListingError> {
let page: ListModelsResponse = serde_json::from_slice(body).map_err(|error| {
ModelListingError::parse_error_with_context("Anthropic", path, &error, body)
})?;
let next_cursor = page.last_id.filter(|cursor| !cursor.is_empty());
if page.has_more && next_cursor.is_none() {
tracing::warn!(
"Anthropic model listing reported more pages but no usable `last_id` cursor; \
returning the pages fetched so far"
);
}
Ok(internal::model_listing::ListingPage {
models: page.data.into_iter().map(Model::from).collect(),
next_cursor: page.has_more.then_some(next_cursor).flatten(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::{MockHttpResponse, SequencedHttpClient};
fn page(models: &[&str], has_more: bool, last_id: Option<&str>) -> MockHttpResponse {
let data: Vec<_> = models
.iter()
.map(|id| serde_json::json!({"id": id, "display_name": id, "type": "model"}))
.collect();
MockHttpResponse::success(
serde_json::json!({
"data": data,
"has_more": has_more,
"last_id": last_id,
})
.to_string(),
)
}
fn lister(
pages: Vec<MockHttpResponse>,
) -> (
AnthropicModelLister<SequencedHttpClient>,
SequencedHttpClient,
) {
let http_client = SequencedHttpClient::new(pages);
let client = Client::builder()
.api_key("test-key")
.http_client(http_client.clone())
.build()
.expect("client should build");
(AnthropicModelLister::new(client), http_client)
}
#[tokio::test]
async fn pagination_follows_the_cursor_across_pages() {
let (lister, http_client) = lister(vec![
page(&["claude-a"], true, Some("claude-a")),
page(&["claude-b"], true, Some("claude-b")),
page(&["claude-c"], false, Some("claude-c")),
]);
let models = lister.list_all().await.expect("listing should succeed");
let ids: Vec<_> = models.data.iter().map(|model| model.id.as_str()).collect();
assert_eq!(ids, ["claude-a", "claude-b", "claude-c"]);
let uris: Vec<_> = http_client
.requests()
.into_iter()
.map(|request| request.uri)
.collect();
assert!(
uris[0].ends_with("/v1/models"),
"first page is uncursored: {uris:?}"
);
assert!(
uris[1].ends_with("/v1/models?after_id=claude-a"),
"second page must carry the first page's cursor: {uris:?}",
);
assert!(
uris[2].ends_with("/v1/models?after_id=claude-b"),
"third page must carry the second page's cursor: {uris:?}",
);
}
#[tokio::test]
async fn pagination_stops_when_a_page_claims_more_but_names_no_cursor() {
let (lister, http_client) = lister(vec![
page(&["claude-a"], true, None),
page(&["claude-a"], true, None),
page(&["claude-a"], true, None),
]);
let models = lister
.list_all()
.await
.expect("a cursor-less page ends the listing instead of looping");
let ids: Vec<_> = models.data.iter().map(|model| model.id.as_str()).collect();
assert_eq!(
ids,
["claude-a"],
"the page's models are returned exactly once"
);
assert_eq!(
http_client.remaining_responses(),
2,
"the loop must stop after the first page rather than re-requesting it",
);
}
#[tokio::test]
async fn pagination_stops_on_an_empty_page_claiming_more() {
let (lister, http_client) = lister(vec![
page(&[], true, None),
page(&["claude-a"], false, None),
]);
let models = lister.list_all().await.expect("listing should terminate");
assert!(models.data.is_empty());
assert_eq!(http_client.remaining_responses(), 1);
}
#[tokio::test]
async fn pagination_stops_on_an_empty_cursor() {
let (lister, http_client) = lister(vec![
page(&["claude-a"], true, Some("")),
page(&["claude-b"], false, None),
]);
let models = lister.list_all().await.expect("listing should terminate");
let ids: Vec<_> = models.data.iter().map(|model| model.id.as_str()).collect();
assert_eq!(ids, ["claude-a"]);
assert_eq!(http_client.remaining_responses(), 1);
}
#[tokio::test]
async fn stops_when_the_last_page_names_no_cursor() {
let (lister, http_client) = lister(vec![page(&["claude-a"], false, None)]);
let models = lister.list_all().await.expect("listing should succeed");
assert_eq!(models.data.len(), 1);
assert_eq!(http_client.remaining_responses(), 0);
}
#[tokio::test]
async fn pagination_stops_midway_and_keeps_earlier_pages() {
let (lister, http_client) = lister(vec![
page(&["claude-a"], true, Some("claude-a")),
page(&["claude-b"], true, None),
page(&["claude-c"], false, None),
]);
let models = lister.list_all().await.expect("listing should terminate");
let ids: Vec<_> = models.data.iter().map(|model| model.id.as_str()).collect();
assert_eq!(
ids,
["claude-a", "claude-b"],
"both fetched pages are kept, in order",
);
assert_eq!(
http_client.remaining_responses(),
1,
"the loop stops at the cursor-less page",
);
}
#[tokio::test]
async fn pagination_stops_on_a_cursor_that_does_not_advance() {
let (lister, http_client) = lister(vec![
page(&["claude-a"], true, Some("stuck")),
page(&["claude-b"], true, Some("stuck")),
page(&["claude-c"], true, Some("stuck")),
]);
let models = lister.list_all().await.expect("listing should terminate");
let ids: Vec<_> = models.data.iter().map(|model| model.id.as_str()).collect();
assert_eq!(
ids,
["claude-a", "claude-b"],
"the repeat is only detectable on the second page, so both are kept",
);
assert_eq!(http_client.remaining_responses(), 1);
}
#[tokio::test]
async fn pagination_stops_at_the_page_ceiling_on_an_alternating_cursor() {
use crate::providers::internal::model_listing::MAX_LISTING_PAGES;
let pages: Vec<_> = (0..MAX_LISTING_PAGES + 10)
.map(|i| {
page(
&["claude-a"],
true,
Some(if i % 2 == 0 { "ping" } else { "pong" }),
)
})
.collect();
let (lister, http_client) = lister(pages);
let models = lister
.list_all()
.await
.expect("the ceiling ends the listing instead of looping");
assert_eq!(
models.data.len(),
MAX_LISTING_PAGES,
"exactly the ceiling's worth of pages is fetched",
);
assert_eq!(
http_client.remaining_responses(),
10,
"the loop stops at the ceiling rather than draining every page",
);
}
#[tokio::test]
async fn pagination_percent_encodes_the_cursor() {
let (lister, http_client) = lister(vec![
page(&["a"], true, Some("weird id&x=1")),
page(&["b"], false, None),
]);
lister.list_all().await.expect("listing should succeed");
let uris: Vec<_> = http_client
.requests()
.into_iter()
.map(|request| request.uri)
.collect();
assert!(
uris[1].ends_with("/v1/models?after_id=weird+id%26x%3D1"),
"the cursor must be percent-encoded: {uris:?}",
);
}
#[tokio::test]
async fn single_page_listing_is_unchanged() {
let (lister, http_client) = lister(vec![page(
&["claude-a", "claude-b"],
false,
Some("claude-b"),
)]);
let models = lister.list_all().await.expect("listing should succeed");
let ids: Vec<_> = models.data.iter().map(|model| model.id.as_str()).collect();
assert_eq!(ids, ["claude-a", "claude-b"]);
assert_eq!(http_client.remaining_responses(), 0);
}
}