use std::time::Duration;
use serde::Deserialize;
use super::anthropic::ANTHROPIC_VERSION;
const TOTAL_TIMEOUT: Duration = Duration::from_secs(20);
const PAGE_SIZE: &str = "1000";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CatalogueShape {
OpenAi,
Gemini,
Anthropic,
Xai,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModelRole {
Chat,
Embedding,
Other,
Unstated,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModelSlot {
Assistant,
Impersonation,
Embedder,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CatalogModel {
pub id: String,
pub display: Option<String>,
pub role: ModelRole,
pub retiring: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CatalogueError {
NoKey,
Unreachable,
Refused(u16),
Unreadable,
NotConfigured,
}
impl CatalogueError {
pub fn message_key(self) -> &'static str {
match self {
CatalogueError::NoKey => "ui.settings.models.err.no_key",
CatalogueError::Unreachable => "ui.settings.models.err.unreachable",
CatalogueError::Refused(_) => "ui.settings.models.err.refused",
CatalogueError::Unreadable => "ui.settings.models.err.unreadable",
CatalogueError::NotConfigured => "ui.settings.models.err.not_configured",
}
}
}
pub type CatalogueAnswer = Result<std::sync::Arc<[CatalogModel]>, CatalogueError>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CatalogueRequest {
pub shape: CatalogueShape,
pub base: String,
pub key: Option<String>,
}
impl CatalogueRequest {
fn url(&self) -> String {
let base = self.base.trim_end_matches('/');
match self.shape {
CatalogueShape::OpenAi => format!("{base}/models"),
CatalogueShape::Gemini => format!("{base}/models?pageSize={PAGE_SIZE}"),
CatalogueShape::Anthropic => format!("{base}/v1/models?limit={PAGE_SIZE}"),
CatalogueShape::Xai => format!("{base}/language-models"),
}
}
}
pub async fn fetch(req: &CatalogueRequest) -> Result<Vec<CatalogModel>, CatalogueError> {
let http = reqwest::Client::builder()
.connect_timeout(super::http::CONNECT_TIMEOUT)
.timeout(TOTAL_TIMEOUT)
.build()
.unwrap_or_else(|_| reqwest::Client::new());
let url = req.url();
let mut rb = http.get(&url);
rb = match (req.shape, req.key.as_deref()) {
(CatalogueShape::Gemini, Some(key)) => rb.header("x-goog-api-key", key),
(CatalogueShape::Anthropic, Some(key)) => rb
.header("x-api-key", key)
.header("anthropic-version", ANTHROPIC_VERSION),
(_, Some(key)) => rb.bearer_auth(key),
(_, None) => rb,
};
let resp = match rb.send().await {
Ok(r) => r,
Err(err) => {
tracing::debug!(%url, error = %err, "no answer from the model catalogue");
return Err(CatalogueError::Unreachable);
}
};
let status = resp.status();
if !status.is_success() {
tracing::debug!(%url, %status, "the model catalogue was refused");
return Err(CatalogueError::Refused(status.as_u16()));
}
let body = resp.text().await.map_err(|err| {
tracing::debug!(%url, error = %err, "the catalogue body could not be read");
CatalogueError::Unreachable
})?;
parse(req.shape, &body)
}
pub fn parse(shape: CatalogueShape, body: &str) -> Result<Vec<CatalogModel>, CatalogueError> {
let unreadable = |err: serde_json::Error| {
tracing::debug!(error = %err, "the model catalogue did not parse");
CatalogueError::Unreadable
};
let mut models: Vec<CatalogModel> = match shape {
CatalogueShape::OpenAi => {
let list: OpenAiList = serde_json::from_str(body).map_err(unreadable)?;
let mut v: Vec<_> = list
.data
.into_iter()
.filter(|e| !e.id.is_empty())
.map(|e| {
(
e.created.unwrap_or(0),
CatalogModel {
id: e.id,
display: None,
role: ModelRole::Unstated,
retiring: e.shutdown_date.filter(|d| !d.is_empty()),
},
)
})
.collect();
v.sort_by_key(|(created, _)| std::cmp::Reverse(*created));
v.into_iter().map(|(_, m)| m).collect()
}
CatalogueShape::Gemini => {
let list: GeminiList = serde_json::from_str(body).map_err(unreadable)?;
let entries = if list.models.is_empty() {
list.data
} else {
list.models
};
entries
.into_iter()
.filter_map(|e| {
let id = e
.name
.strip_prefix("models/")
.unwrap_or(&e.name)
.to_string();
(!id.is_empty()).then(|| CatalogModel {
id,
display: e.display_name.filter(|d| !d.is_empty()),
role: gemini_role(&e.supported_generation_methods),
retiring: None,
})
})
.collect()
}
CatalogueShape::Anthropic => {
let list: AnthropicList = serde_json::from_str(body).map_err(unreadable)?;
let mut v: Vec<_> = list
.data
.into_iter()
.filter(|e| !e.id.is_empty())
.map(|e| {
(
e.created_at.clone().unwrap_or_default(),
CatalogModel {
id: e.id,
display: e.display_name.filter(|d| !d.is_empty()),
role: ModelRole::Chat,
retiring: None,
},
)
})
.collect();
v.sort_by_key(|(created_at, _)| std::cmp::Reverse(created_at.clone()));
v.into_iter().map(|(_, m)| m).collect()
}
CatalogueShape::Xai => {
let list: XaiList = serde_json::from_str(body).map_err(unreadable)?;
let mut v: Vec<_> = list
.models
.into_iter()
.filter(|e| !e.id.is_empty())
.map(|e| {
(
e.created.unwrap_or(0),
CatalogModel {
id: e.id,
display: None,
role: ModelRole::Chat,
retiring: None,
},
)
})
.collect();
v.sort_by_key(|(created, _)| std::cmp::Reverse(*created));
v.into_iter().map(|(_, m)| m).collect()
}
};
models.dedup_by(|a, b| a.id == b.id);
Ok(models)
}
fn gemini_role(methods: &[String]) -> ModelRole {
if methods.iter().any(|m| m == "generateContent") {
ModelRole::Chat
} else if methods.iter().any(|m| m == "embedContent") {
ModelRole::Embedding
} else if methods.is_empty() {
ModelRole::Unstated
} else {
ModelRole::Other
}
}
const ANOTHER_JOB_IN_A_NAME: [&str; 17] = [
"tts",
"whisper",
"image",
"sora",
"embedding",
"realtime",
"transcribe",
"moderation",
"audio",
"dall-e",
"live",
"speech",
"video",
"imagine",
"veo",
"lyria",
"music",
];
const EMBEDDING_IN_A_NAME: [&str; 1] = ["embed"];
fn name_hints(id: &str, needles: &[&str]) -> bool {
let id = id.to_ascii_lowercase();
needles.iter().any(|n| id.contains(n))
}
fn rank(m: &CatalogModel, slot: ModelSlot) -> u8 {
match slot {
ModelSlot::Assistant | ModelSlot::Impersonation => {
u8::from(m.role == ModelRole::Unstated && name_hints(&m.id, &ANOTHER_JOB_IN_A_NAME))
}
ModelSlot::Embedder => u8::from(
!(m.role == ModelRole::Embedding
|| (m.role == ModelRole::Unstated && name_hints(&m.id, &EMBEDDING_IN_A_NAME))),
),
}
}
pub fn for_slot(models: Vec<CatalogModel>, slot: ModelSlot) -> Vec<CatalogModel> {
let mut kept: Vec<CatalogModel> = models
.into_iter()
.filter(|m| match (slot, m.role) {
(_, ModelRole::Unstated) => true,
(ModelSlot::Assistant | ModelSlot::Impersonation, role) => role == ModelRole::Chat,
(ModelSlot::Embedder, role) => role == ModelRole::Embedding,
})
.collect();
kept.sort_by_key(|m| rank(m, slot));
kept
}
#[derive(Deserialize)]
struct OpenAiList {
#[serde(default)]
data: Vec<OpenAiEntry>,
}
#[derive(Deserialize)]
struct OpenAiEntry {
id: String,
#[serde(default)]
created: Option<i64>,
#[serde(default)]
shutdown_date: Option<String>,
}
#[derive(Deserialize)]
struct GeminiList {
#[serde(default)]
models: Vec<GeminiEntry>,
#[serde(default)]
data: Vec<GeminiEntry>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct GeminiEntry {
#[serde(default, alias = "id")]
name: String,
#[serde(default, alias = "display_name")]
display_name: Option<String>,
#[serde(default)]
supported_generation_methods: Vec<String>,
}
#[derive(Deserialize)]
struct AnthropicList {
#[serde(default)]
data: Vec<AnthropicEntry>,
}
#[derive(Deserialize)]
struct AnthropicEntry {
id: String,
#[serde(default)]
display_name: Option<String>,
#[serde(default)]
created_at: Option<String>,
}
#[derive(Deserialize)]
struct XaiList {
#[serde(default)]
models: Vec<XaiEntry>,
}
#[derive(Deserialize)]
struct XaiEntry {
id: String,
#[serde(default)]
created: Option<i64>,
}
#[cfg(test)]
mod tests {
use super::*;
const OPENAI_BODY: &str = r#"{"object":"list","data":[
{"id":"whisper-1","object":"model","created":1677532384,"owned_by":"openai-internal","shutdown_date":null},
{"id":"gpt-4","object":"model","created":1687882411,"owned_by":"openai","shutdown_date":"2026-10-23"},
{"id":"gpt-6-astra","object":"model","created":1779000000,"owned_by":"system","shutdown_date":null},
{"id":"text-embedding-3-small","object":"model","created":1705948997,"owned_by":"system","shutdown_date":null}
]}"#;
const GEMINI_BODY: &str = r#"{"models":[
{"name":"models/gemini-2.5-flash","displayName":"Gemini 2.5 Flash","inputTokenLimit":1048576,
"supportedGenerationMethods":["generateContent","countTokens","batchGenerateContent"]},
{"name":"models/gemini-embedding-001","displayName":"Gemini Embedding 001",
"supportedGenerationMethods":["embedContent","countTokens","asyncBatchEmbedContent"]},
{"name":"models/veo-3.1-generate-preview","displayName":"Veo 3.1",
"supportedGenerationMethods":["predictLongRunning"]}
]}"#;
const ANTHROPIC_BODY: &str = r#"{"data":[
{"type":"model","id":"claude-opus-4-8","display_name":"Claude Opus 4.8","created_at":"2026-02-05T00:00:00Z"},
{"type":"model","id":"claude-opus-5","display_name":"Claude Opus 5","created_at":"2026-07-24T00:00:00Z"}
],"has_more":false}"#;
const XAI_BODY: &str = r#"{"models":[
{"id":"grok-4.5","aliases":["grok-4.5-latest"],"created":1770000000,
"input_modalities":["text","image"],"output_modalities":["text"]},
{"id":"grok-4.6","aliases":[],"created":1780000000,
"input_modalities":["text","image"],"output_modalities":["text"]}
]}"#;
#[test]
fn openai_lists_everything_newest_first_and_claims_nothing() {
let models = parse(CatalogueShape::OpenAi, OPENAI_BODY).expect("a catalogue");
assert_eq!(
models.iter().map(|m| m.id.as_str()).collect::<Vec<_>>(),
[
"gpt-6-astra",
"text-embedding-3-small",
"gpt-4",
"whisper-1"
],
"newest first, by the `created` the endpoint publishes"
);
assert!(
models.iter().all(|m| m.role == ModelRole::Unstated),
"OpenAI publishes no capability field, so no role may be claimed"
);
let gpt4 = models.iter().find(|m| m.id == "gpt-4").expect("gpt-4");
assert_eq!(gpt4.retiring.as_deref(), Some("2026-10-23"));
assert!(
models.iter().filter(|m| m.retiring.is_some()).count() == 1,
"a null shutdown_date is not a date"
);
}
#[test]
fn a_silent_catalogue_is_ordered_not_filtered() {
let models = parse(CatalogueShape::OpenAi, OPENAI_BODY).expect("a catalogue");
let chat = for_slot(models.clone(), ModelSlot::Assistant);
assert_eq!(chat.len(), models.len(), "nothing is hidden, ever");
assert_eq!(
chat.iter().map(|m| m.id.as_str()).collect::<Vec<_>>(),
[
"gpt-6-astra",
"gpt-4",
"text-embedding-3-small",
"whisper-1"
],
"the chat models first, each group still newest-first"
);
let embed = for_slot(models.clone(), ModelSlot::Embedder);
assert_eq!(embed.len(), models.len());
assert_eq!(
embed[0].id, "text-embedding-3-small",
"the embedder's row wants the mirror of the same guess"
);
assert!(
chat.iter().any(|m| m.id == "whisper-1"),
"a model we guessed about is still there to be chosen"
);
}
#[test]
fn an_unstated_role_is_offered_for_every_slot() {
let models = parse(CatalogueShape::OpenAi, OPENAI_BODY).expect("a catalogue");
for slot in [
ModelSlot::Assistant,
ModelSlot::Impersonation,
ModelSlot::Embedder,
] {
assert_eq!(
for_slot(models.clone(), slot).len(),
4,
"silence narrows nothing — {slot:?} sees the whole list"
);
}
}
#[test]
fn gemini_strips_the_prefix_and_reads_the_published_methods() {
let models = parse(CatalogueShape::Gemini, GEMINI_BODY).expect("a catalogue");
assert_eq!(
models.iter().map(|m| m.id.as_str()).collect::<Vec<_>>(),
[
"gemini-2.5-flash",
"gemini-embedding-001",
"veo-3.1-generate-preview"
],
"the `models/` prefix is the client's to add, and the order is the endpoint's"
);
assert_eq!(models[0].role, ModelRole::Chat);
assert_eq!(models[1].role, ModelRole::Embedding);
assert_eq!(models[2].role, ModelRole::Other);
assert_eq!(models[0].display.as_deref(), Some("Gemini 2.5 Flash"));
}
#[test]
fn gemini_narrows_per_slot() {
let models = parse(CatalogueShape::Gemini, GEMINI_BODY).expect("a catalogue");
assert_eq!(
for_slot(models.clone(), ModelSlot::Assistant)
.iter()
.map(|m| m.id.as_str())
.collect::<Vec<_>>(),
["gemini-2.5-flash"]
);
assert_eq!(
for_slot(models, ModelSlot::Embedder)
.iter()
.map(|m| m.id.as_str())
.collect::<Vec<_>>(),
["gemini-embedding-001"],
"the embedder gets what the endpoint says embeds, not what the name suggests"
);
}
#[test]
fn anthropic_is_chat_newest_first() {
let models = parse(CatalogueShape::Anthropic, ANTHROPIC_BODY).expect("a catalogue");
assert_eq!(
models.iter().map(|m| m.id.as_str()).collect::<Vec<_>>(),
["claude-opus-5", "claude-opus-4-8"]
);
assert!(models.iter().all(|m| m.role == ModelRole::Chat));
assert_eq!(models[0].display.as_deref(), Some("Claude Opus 5"));
assert!(
for_slot(models, ModelSlot::Embedder).is_empty(),
"Anthropic publishes no embedding model, and an empty list says so"
);
}
#[test]
fn xai_reads_the_models_key_not_data() {
let models = parse(CatalogueShape::Xai, XAI_BODY).expect("a catalogue");
assert_eq!(
models.iter().map(|m| m.id.as_str()).collect::<Vec<_>>(),
["grok-4.6", "grok-4.5"],
"the language half of the catalogue, newest first"
);
assert!(models.iter().all(|m| m.role == ModelRole::Chat));
}
#[test]
fn a_llama_server_lists_its_path_and_it_is_kept_as_is() {
let body = r#"{"object":"list","data":[
{"id":"D:\\LLM\\GGUF\\gemma-4-31B_q4_0-it.gguf","object":"model","created":1789691057,"owned_by":"llamacpp"}
],"models":[{"name":"D:\\LLM\\GGUF\\gemma-4-31B_q4_0-it.gguf","capabilities":["completion","multimodal"]}]}"#;
let models = parse(CatalogueShape::OpenAi, body).expect("a catalogue");
assert_eq!(models.len(), 1);
assert_eq!(models[0].id, r"D:\LLM\GGUF\gemma-4-31B_q4_0-it.gguf");
assert_eq!(models[0].role, ModelRole::Unstated);
}
#[test]
fn a_body_that_is_not_a_catalogue_is_unreadable() {
assert_eq!(
parse(CatalogueShape::OpenAi, "<html>404</html>"),
Err(CatalogueError::Unreadable)
);
assert_eq!(parse(CatalogueShape::OpenAi, "{}"), Ok(vec![]));
assert_eq!(parse(CatalogueShape::Gemini, "{}"), Ok(vec![]));
}
#[test]
fn every_shape_has_its_own_route() {
let req = |shape| CatalogueRequest {
shape,
base: "https://example.test/v1/".to_string(),
key: None,
};
assert_eq!(
req(CatalogueShape::OpenAi).url(),
"https://example.test/v1/models"
);
assert_eq!(
req(CatalogueShape::Gemini).url(),
"https://example.test/v1/models?pageSize=1000",
"unpaged, Gemini answers 50 of its 58 models"
);
assert_eq!(
req(CatalogueShape::Xai).url(),
"https://example.test/v1/language-models"
);
assert_eq!(
CatalogueRequest {
shape: CatalogueShape::Anthropic,
base: "https://api.anthropic.com".to_string(),
key: None,
}
.url(),
"https://api.anthropic.com/v1/models?limit=1000",
"the Anthropic base carries no version segment"
);
}
}
#[cfg(test)]
mod live_smoke {
use super::*;
fn report(name: &str, models: &[CatalogModel]) {
let chat = models.iter().filter(|m| m.role == ModelRole::Chat).count();
let embed = models
.iter()
.filter(|m| m.role == ModelRole::Embedding)
.count();
let unstated = models
.iter()
.filter(|m| m.role == ModelRole::Unstated)
.count();
let retiring = models.iter().filter(|m| m.retiring.is_some()).count();
eprintln!(
"{name}: {} entries — chat {chat}, embedding {embed}, unstated {unstated}, retiring {retiring}",
models.len()
);
for m in models.iter().take(5) {
eprintln!(" {} {:?} {:?}", m.id, m.display, m.role);
}
}
async fn fetched(shape: CatalogueShape, base: &str, key: Option<String>) -> Vec<CatalogModel> {
super::fetch(&CatalogueRequest {
shape,
base: base.to_string(),
key,
})
.await
.expect("the catalogue answered")
}
#[tokio::test]
#[ignore = "requires MINDFORK_OPENAI_KEY (live OpenAI API)"]
async fn the_openai_catalogue_e2e_live() {
let Ok(key) = std::env::var("MINDFORK_OPENAI_KEY") else {
eprintln!("skip: MINDFORK_OPENAI_KEY not set");
return;
};
let models = fetched(
CatalogueShape::OpenAi,
"https://api.openai.com/v1",
Some(key),
)
.await;
report("openai", &models);
assert!(models.len() > 10, "a catalogue of {}", models.len());
assert!(models.iter().all(|m| !m.id.is_empty()));
assert!(
models.iter().all(|m| m.role == ModelRole::Unstated),
"OpenAI publishes no capability field — no role may be claimed"
);
assert!(
models.iter().any(|m| m.retiring.is_some()),
"the shutdown dates are what the endpoint does publish"
);
let chat = for_slot(models.clone(), ModelSlot::Assistant);
assert_eq!(chat.len(), models.len(), "silence narrows nothing");
eprintln!(
"openai, ordered for chat: {:?}",
chat.iter()
.take(5)
.map(|m| m.id.as_str())
.collect::<Vec<_>>()
);
assert!(
!name_hints(&chat[0].id, &ANOTHER_JOB_IN_A_NAME),
"the list opens on a chat model, not on this month's image one: {}",
chat[0].id
);
let embed = for_slot(models, ModelSlot::Embedder);
eprintln!("openai, ordered for the embedder: {}", embed[0].id);
assert!(
name_hints(&embed[0].id, &EMBEDDING_IN_A_NAME),
"and the embedder's row opens on one that embeds: {}",
embed[0].id
);
}
#[tokio::test]
#[ignore = "requires MINDFORK_GEMINI_KEY (live Gemini API)"]
async fn the_gemini_catalogue_e2e_live() {
let Ok(key) = std::env::var("MINDFORK_GEMINI_KEY") else {
eprintln!("skip: MINDFORK_GEMINI_KEY not set");
return;
};
let models = fetched(
CatalogueShape::Gemini,
"https://generativelanguage.googleapis.com/v1beta",
Some(key),
)
.await;
report("gemini", &models);
assert!(models.len() > 10);
assert!(
models.iter().all(|m| !m.id.starts_with("models/")),
"the prefix belongs to the URL the client builds, not to the field"
);
let chat = for_slot(models.clone(), ModelSlot::Assistant);
let embed = for_slot(models.clone(), ModelSlot::Embedder);
assert!(!chat.is_empty() && !embed.is_empty());
assert!(
chat.len() < models.len(),
"supportedGenerationMethods is what narrows the list, and it does"
);
assert!(
embed.iter().all(|m| !chat.iter().any(|c| c.id == m.id)),
"a model the endpoint calls an embedder is not offered for chat"
);
}
#[tokio::test]
#[ignore = "requires MINDFORK_ANTHROPIC_KEY (live Anthropic API)"]
async fn the_anthropic_catalogue_e2e_live() {
let Ok(key) = std::env::var("MINDFORK_ANTHROPIC_KEY") else {
eprintln!("skip: MINDFORK_ANTHROPIC_KEY not set");
return;
};
let models = fetched(
CatalogueShape::Anthropic,
"https://api.anthropic.com",
Some(key),
)
.await;
report("anthropic", &models);
assert!(!models.is_empty());
assert!(models.iter().all(|m| m.role == ModelRole::Chat));
assert!(
models.iter().all(|m| m.display.is_some()),
"Anthropic publishes a display name for every model"
);
assert!(
for_slot(models, ModelSlot::Embedder).is_empty(),
"Anthropic has no embedding model, and the empty list says so"
);
}
#[tokio::test]
#[ignore = "requires MINDFORK_GROK_KEY (live xAI API)"]
async fn the_xai_catalogue_e2e_live() {
let Ok(key) = std::env::var("MINDFORK_GROK_KEY") else {
eprintln!("skip: MINDFORK_GROK_KEY not set");
return;
};
let models = fetched(CatalogueShape::Xai, "https://api.x.ai/v1", Some(key)).await;
report("xai", &models);
assert!(!models.is_empty(), "the `models` key, not `data`");
assert!(models.iter().all(|m| m.role == ModelRole::Chat));
assert!(
models.iter().all(|m| !m.id.contains("imagine")),
"the image and video models live under /models, which is why this route is asked"
);
}
#[tokio::test]
#[ignore = "requires MINDFORK_ENGINE_URL (a live OpenAI-compatible server)"]
async fn the_local_server_catalogue_e2e_live() {
let Ok(base) = std::env::var("MINDFORK_ENGINE_URL") else {
eprintln!("skip: MINDFORK_ENGINE_URL not set");
return;
};
let key = std::env::var("MINDFORK_ENGINE_KEY")
.ok()
.filter(|k| !k.is_empty());
let models = fetched(CatalogueShape::OpenAi, &base, key).await;
report("external", &models);
assert!(!models.is_empty(), "a server with a model loaded lists it");
assert!(models.iter().all(|m| !m.id.is_empty()));
assert!(
models.iter().all(|m| m.role == ModelRole::Unstated),
"a llama.cpp publishes no role in the OpenAI list"
);
}
}