use crate::{
client::{self, ModelLister, Provider},
http_client::HttpClientExt,
model::{Model, ModelList, ModelListingError},
providers::{
gemini::{Client, InteractionsClient},
internal,
},
wasm_compat::{WasmCompatSend, WasmCompatSync},
};
use serde::Deserialize;
use std::{convert::TryFrom, fmt};
const MAX_PAGE_SIZE: usize = 1000;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ListModelsResponse {
#[serde(default)]
models: Vec<ListModelEntry>,
next_page_token: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ListModelEntry {
#[serde(default)]
name: String,
base_model_id: Option<String>,
display_name: Option<String>,
description: Option<String>,
input_token_limit: Option<u64>,
output_token_limit: Option<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct MissingModelIdError;
impl fmt::Display for MissingModelIdError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"parse_error=model entry missing usable `baseModelId` and `name` values"
)
}
}
impl std::error::Error for MissingModelIdError {}
fn normalize_gemini_model_id(name: &str) -> Option<String> {
let trimmed = name.trim();
let trimmed = trimmed.strip_prefix("models/").unwrap_or(trimmed);
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_owned())
}
}
impl TryFrom<ListModelEntry> for Model {
type Error = MissingModelIdError;
fn try_from(value: ListModelEntry) -> Result<Self, Self::Error> {
let id = value
.base_model_id
.as_deref()
.map(str::trim)
.filter(|id| !id.is_empty())
.map(str::to_owned)
.or_else(|| normalize_gemini_model_id(&value.name))
.ok_or(MissingModelIdError)?;
let mut model = Model::from_id(id);
model.name = value.display_name;
model.description = value.description;
model.context_length = value
.input_token_limit
.and_then(|limit| u32::try_from(limit).ok());
model.max_output_tokens = value
.output_token_limit
.and_then(|limit| u32::try_from(limit).ok());
Ok(model)
}
}
fn list_models_path(page_token: Option<&str>) -> String {
let page_size = MAX_PAGE_SIZE.to_string();
let mut pairs = vec![("pageSize", page_size.as_str())];
if let Some(page_token) = page_token {
pairs.push(("pageToken", page_token));
}
internal::model_listing::with_query_pairs("/v1beta/models", &pairs)
}
fn parse_models_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("Gemini", path, &error, body)
})?;
let models = page
.models
.into_iter()
.map(|entry| {
Model::try_from(entry).map_err(|error| {
ModelListingError::parse_error_with_details("Gemini", path, error, body)
})
})
.collect::<Result<Vec<_>, _>>()?;
Ok(internal::model_listing::ListingPage {
models,
next_cursor: page.next_page_token.filter(|token| !token.is_empty()),
})
}
async fn list_all_models<Ext, H>(
client: &client::Client<Ext, H>,
) -> Result<ModelList, ModelListingError>
where
Ext: Provider + WasmCompatSend + WasmCompatSync + 'static,
H: HttpClientExt + WasmCompatSend + WasmCompatSync + 'static,
{
internal::model_listing::paginate_models(client, "Gemini", list_models_path, parse_models_page)
.await
}
#[derive(Clone)]
pub struct GeminiModelLister<H = reqwest::Client> {
client: Client<H>,
}
impl<H> ModelLister<H> for GeminiModelLister<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> {
list_all_models(&self.client).await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_models_page_accepts_omitted_empty_models_list() {
let page =
parse_models_page(br#"{}"#, "/v1beta/models?pageSize=1000").expect("page should parse");
let (models, next_page_token) = (page.models, page.next_cursor);
assert!(models.is_empty());
assert_eq!(next_page_token, None);
}
#[test]
fn list_models_path_puts_page_size_first_and_encodes_the_cursor() {
assert_eq!(list_models_path(None), "/v1beta/models?pageSize=1000");
assert_eq!(
list_models_path(Some("abc123")),
"/v1beta/models?pageSize=1000&pageToken=abc123",
);
assert_eq!(
list_models_path(Some("weird token&x=1")),
"/v1beta/models?pageSize=1000&pageToken=weird+token%26x%3D1",
);
}
#[test]
fn parse_models_page_treats_an_empty_next_page_token_as_absent() {
let next_page_token = parse_models_page(
br#"{"models": [], "nextPageToken": ""}"#,
"/v1beta/models?pageSize=1000",
)
.expect("page should parse")
.next_cursor;
assert_eq!(next_page_token, None);
}
#[test]
fn parse_models_page_keeps_a_non_empty_next_page_token() {
let next_page_token = parse_models_page(
br#"{"models": [], "nextPageToken": "abc123"}"#,
"/v1beta/models?pageSize=1000",
)
.expect("page should parse")
.next_cursor;
assert_eq!(next_page_token.as_deref(), Some("abc123"));
}
#[tokio::test]
async fn list_all_stops_on_a_cursor_that_does_not_advance() {
use crate::client::ModelLister as _;
use crate::test_utils::{MockHttpResponse, SequencedHttpClient};
let page = |id: &str, token: &str| {
MockHttpResponse::success(
serde_json::json!({
"models": [{
"name": format!("models/{id}"),
"displayName": id,
"inputTokenLimit": 1024
}],
"nextPageToken": token
})
.to_string(),
)
};
let http_client = SequencedHttpClient::new(vec![
page("a", "stuck"),
page("b", "stuck"),
page("c", "stuck"),
]);
let client = Client::builder()
.api_key("test-key")
.http_client(http_client.clone())
.build()
.expect("client should build");
let models = GeminiModelLister::new(client)
.list_all()
.await
.expect("listing should terminate");
assert_eq!(
models.data.len(),
2,
"the repeat is only detectable on the second page, so both are kept",
);
assert_eq!(http_client.remaining_responses(), 1);
}
#[test]
fn parse_models_page_falls_back_to_name_when_base_model_id_is_missing() {
let body = br#"{
"models": [
{
"name": "models/gemini-2.0-flash-001",
"displayName": "Gemini 2.0 Flash 001",
"description": "Stable Gemini 2.0 Flash",
"inputTokenLimit": 1048576
}
]
}"#;
let page =
parse_models_page(body, "/v1beta/models?pageSize=1000").expect("page should parse");
let (models, next_page_token) = (page.models, page.next_cursor);
assert_eq!(next_page_token, None);
assert_eq!(models.len(), 1);
assert_eq!(models[0].id, "gemini-2.0-flash-001");
assert_eq!(models[0].name.as_deref(), Some("Gemini 2.0 Flash 001"));
assert_eq!(
models[0].description.as_deref(),
Some("Stable Gemini 2.0 Flash")
);
assert_eq!(models[0].context_length, Some(1_048_576));
}
#[test]
fn parse_models_page_prefers_base_model_id_when_present() {
let body = br#"{
"models": [
{
"name": "models/gemini-2.0-flash-001",
"baseModelId": "gemini-2.0-flash",
"displayName": "Gemini 2.0 Flash 001"
}
]
}"#;
let models = parse_models_page(body, "/v1beta/models?pageSize=1000")
.expect("page should parse")
.models;
assert_eq!(models.len(), 1);
assert_eq!(models[0].id, "gemini-2.0-flash");
}
#[test]
fn parse_models_page_reports_missing_model_id_when_name_is_omitted() {
let error = parse_models_page(br#"{"models":[{}]}"#, "/v1beta/models?pageSize=1000")
.expect_err("entry without name/baseModelId should fail with contextual error");
match error {
ModelListingError::ParseError { message } => {
assert!(message.contains("provider=Gemini"));
assert!(message.contains("path=/v1beta/models?pageSize=1000"));
assert!(message.contains(
"parse_error=model entry missing usable `baseModelId` and `name` values"
));
}
_ => panic!("expected parse error"),
}
}
#[test]
fn parse_models_page_returns_parse_error_when_entry_has_no_usable_id() {
let body = br#"{
"models": [
{
"name": "models/",
"baseModelId": " ",
"displayName": "Broken Gemini"
}
]
}"#;
let error = parse_models_page(body, "/v1beta/models?pageSize=1000")
.expect_err("page should fail when no usable ID is available");
match error {
ModelListingError::ParseError { message } => {
assert!(message.contains("provider=Gemini"));
assert!(message.contains("path=/v1beta/models?pageSize=1000"));
assert!(message.contains(
"parse_error=model entry missing usable `baseModelId` and `name` values"
));
assert!(message.contains(r#""name": "models/""#));
}
_ => panic!("expected parse error"),
}
}
}
#[derive(Clone)]
pub struct GeminiInteractionsModelLister<H = reqwest::Client> {
client: InteractionsClient<H>,
}
impl<H> ModelLister<H> for GeminiInteractionsModelLister<H>
where
H: HttpClientExt + WasmCompatSend + WasmCompatSync + 'static,
{
type Client = InteractionsClient<H>;
fn new(client: Self::Client) -> Self {
Self { client }
}
async fn list_all(&self) -> Result<ModelList, ModelListingError> {
list_all_models(&self.client).await
}
}