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,
OpenRouter,
OpenRouterEmbeddings,
OpenRouterSpeech,
OpenRouterVideo,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModelRole {
Chat,
Embedding,
Speech,
Other,
Unstated,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModelSlot {
Assistant,
Impersonation,
Embedder,
Speech,
Video,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CatalogModel {
pub id: String,
pub display: Option<String>,
pub role: ModelRole,
pub retiring: Option<String>,
pub facts: ModelFacts,
pub voices: Vec<String>,
}
impl CatalogModel {
pub fn voice(name: &str) -> Self {
Self {
id: name.to_string(),
display: None,
role: ModelRole::Speech,
retiring: None,
facts: ModelFacts::default(),
voices: Vec::new(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ModelFacts {
pub context_length: Option<u32>,
pub prompt_price: Option<u64>,
pub completion_price: Option<u64>,
pub tools: Option<bool>,
pub video: Option<bool>,
}
impl ModelFacts {
pub fn is_empty(&self) -> bool {
let shown = ModelFacts {
video: None,
..*self
};
shown == ModelFacts::default()
}
}
#[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>,
pub attribution: bool,
}
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"),
CatalogueShape::OpenRouter if self.key.is_some() => format!("{base}/models/user"),
CatalogueShape::OpenRouter => format!("{base}/models"),
CatalogueShape::OpenRouterEmbeddings => format!("{base}/embeddings/models"),
CatalogueShape::OpenRouterSpeech if self.key.is_some() => {
format!("{base}/models/user?output_modalities=speech")
}
CatalogueShape::OpenRouterSpeech => format!("{base}/models?output_modalities=speech"),
CatalogueShape::OpenRouterVideo if self.key.is_some() => format!("{base}/models/user"),
CatalogueShape::OpenRouterVideo => format!("{base}/models?input_modalities=video"),
}
}
}
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,
};
if req.attribution {
rb = super::openai::attributed(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()),
facts: ModelFacts::default(),
voices: Vec::new(),
},
)
})
.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,
facts: ModelFacts::default(),
voices: Vec::new(),
})
})
.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,
facts: ModelFacts::default(),
voices: Vec::new(),
},
)
})
.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,
facts: ModelFacts::default(),
voices: Vec::new(),
},
)
})
.collect();
v.sort_by_key(|(created, _)| std::cmp::Reverse(*created));
v.into_iter().map(|(_, m)| m).collect()
}
CatalogueShape::OpenRouter
| CatalogueShape::OpenRouterEmbeddings
| CatalogueShape::OpenRouterSpeech
| CatalogueShape::OpenRouterVideo => {
let list: GatewayList = serde_json::from_str(body).map_err(unreadable)?;
let mut v: Vec<_> = list
.data
.into_iter()
.filter(|e| !e.id.is_empty() && !e.id.ends_with(":batch"))
.map(|e| (e.created.unwrap_or(0), e.into_model()))
.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 VIDEO_LINK_IN_A_NAME: [&str; 1] = ["google/gemini"];
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))),
),
ModelSlot::Speech => 0,
ModelSlot::Video => u8::from(!name_hints(&m.id, &VIDEO_LINK_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,
(ModelSlot::Speech, role) => role == ModelRole::Speech,
(ModelSlot::Video, role) => role == ModelRole::Chat && m.facts.video != Some(false),
})
.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 GatewayList {
#[serde(default)]
data: Vec<GatewayEntry>,
}
#[derive(Deserialize)]
struct GatewayEntry {
id: String,
#[serde(default)]
name: Option<String>,
#[serde(default)]
created: Option<i64>,
#[serde(default)]
context_length: Option<serde_json::Value>,
#[serde(default)]
pricing: Option<serde_json::Value>,
#[serde(default)]
architecture: Option<serde_json::Value>,
#[serde(default)]
supported_parameters: Option<Vec<String>>,
#[serde(default)]
supported_voices: Option<serde_json::Value>,
#[serde(default)]
expiration_date: Option<String>,
}
impl GatewayEntry {
fn into_model(self) -> CatalogModel {
let outputs: Vec<&str> = self
.architecture
.as_ref()
.and_then(|a| a.get("output_modalities"))
.and_then(|o| o.as_array())
.map(|list| list.iter().filter_map(|m| m.as_str()).collect())
.unwrap_or_default();
let role = if outputs.is_empty() {
ModelRole::Unstated
} else if outputs.contains(&"text") {
ModelRole::Chat
} else if outputs.contains(&"embeddings") {
ModelRole::Embedding
} else if outputs.contains(&"speech") {
ModelRole::Speech
} else {
ModelRole::Other
};
let voices = self
.supported_voices
.as_ref()
.and_then(|v| v.as_array())
.map(|list| {
list.iter()
.filter_map(|v| v.as_str())
.map(str::trim)
.filter(|v| !v.is_empty())
.map(str::to_string)
.collect()
})
.unwrap_or_default();
let price = |key: &str| {
self.pricing
.as_ref()
.and_then(|p| p.get(key))
.and_then(price_per_million)
};
let facts = ModelFacts {
context_length: self
.context_length
.as_ref()
.and_then(serde_json::Value::as_u64)
.and_then(|n| u32::try_from(n).ok())
.filter(|&n| n > 0),
prompt_price: price("prompt"),
completion_price: price("completion"),
tools: self
.supported_parameters
.as_ref()
.filter(|p| !p.is_empty())
.map(|p| p.iter().any(|x| x == "tools")),
video: self
.architecture
.as_ref()
.and_then(|a| a.get("input_modalities"))
.and_then(|i| i.as_array())
.filter(|list| !list.is_empty())
.map(|list| list.iter().any(|m| m.as_str() == Some("video"))),
};
CatalogModel {
id: self.id,
display: self.name.filter(|n| !n.is_empty()),
role,
retiring: self.expiration_date.filter(|d| !d.is_empty()),
facts,
voices,
}
}
}
fn price_per_million(per_token: &serde_json::Value) -> Option<u64> {
let dollars = match per_token {
serde_json::Value::String(s) => s.trim().parse::<f64>().ok()?,
serde_json::Value::Number(n) => n.as_f64()?,
_ => return None,
};
(dollars.is_finite() && dollars >= 0.0).then(|| (dollars * 1e12).round() as u64)
}
#[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,
attribution: false,
};
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,
attribution: false,
}
.url(),
"https://api.anthropic.com/v1/models?limit=1000",
"the Anthropic base carries no version segment"
);
}
const GATEWAY_BODY: &str = r#"{"data":[
{"id":"anthropic/claude-haiku-4.5","name":"Anthropic: Claude Haiku 4.5","created":1760547638,
"context_length":200000,
"architecture":{"input_modalities":["text","image","file"],"output_modalities":["text"]},
"pricing":{"prompt":"0.000001","completion":"0.000005"},
"supported_parameters":["max_tokens","temperature","tools"],"expiration_date":null},
{"id":"anthropic/claude-haiku-4.5:batch","name":"Anthropic: Claude Haiku 4.5 (batch)","created":1760547638,
"context_length":200000,"architecture":{"output_modalities":["text"]},
"pricing":{"prompt":"0.0000005","completion":"0.0000025"},"supported_parameters":["tools"]},
{"id":"openrouter/auto","name":"Auto Router","created":1699401600,"context_length":2000000,
"architecture":{"output_modalities":["text"]},
"pricing":{"prompt":"-1","completion":"-1"},"supported_parameters":["tools"]},
{"id":"google/gemini-3.5-flash","name":"Google: Gemini 3.5 Flash","created":1779000000,
"context_length":1048576,
"architecture":{"input_modalities":["text","image","video"],"output_modalities":["text"]},
"pricing":{"prompt":"0.0000003","completion":"0.0000025"},
"supported_parameters":["reasoning","max_tokens","tools"],"expiration_date":""},
{"id":"meta-llama/llama-3.3-70b-instruct:free","name":"Meta: Llama 3.3 70B (free)","created":1733506137,
"context_length":65536,"architecture":{"output_modalities":["text"]},
"pricing":{"prompt":"0","completion":"0"},"supported_parameters":["max_tokens","temperature"]},
{"id":"qwen/qwen3.6-27b","name":"Qwen: Qwen3.6 27B","created":1775000000,"context_length":131072,
"architecture":{"output_modalities":["text"]},
"pricing":{"prompt":"0.00000012","completion":"0.00000048"},"supported_parameters":["tools"]},
{"id":"openai/gpt-4-turbo","name":"OpenAI: GPT-4 Turbo","created":1712620800,
"context_length":128000,"architecture":{"output_modalities":["text"]},
"pricing":{"prompt":0.00001,"completion":0.00003},"expiration_date":"2026-11-01"},
{"id":"openai/gpt-image-2","name":"","created":1770000000,"context_length":32000,
"architecture":{"output_modalities":["image"]},"pricing":{"prompt":"0.000005"}},
{"id":"odd/shapes","created":1700000000,"context_length":"large",
"architecture":"text->text","pricing":{"prompt":"n/a","completion":null},
"supported_parameters":[]},
{"id":"odd/window","context_length":0,"pricing":"free"}
]}"#;
const GATEWAY_EMBEDDINGS_BODY: &str = r#"{"data":[
{"id":"openai/text-embedding-3-small","name":"OpenAI: Text Embedding 3 Small","created":1706000000,
"context_length":8191,"architecture":{"input_modalities":["text"],"output_modalities":["embeddings"]},
"pricing":{"prompt":"0.00000002","completion":"0"}},
{"id":"baai/bge-m3","name":"BAAI: bge-m3","created":1754000000,"context_length":8192,
"architecture":{"input_modalities":["text"],"output_modalities":["embeddings"]},
"pricing":{"prompt":"0.00000001","completion":"0"}}
]}"#;
const GATEWAY_SPEECH_BODY: &str = r#"{"data":[
{"id":"x-ai/grok-voice-tts-1.0","name":"xAI: Grok Voice TTS 1.0","created":1782000000,
"context_length":15000,
"architecture":{"modality":"text->speech","input_modalities":["text"],"output_modalities":["speech"]},
"pricing":{"prompt":"0.000015","completion":"0"},"supported_parameters":[],
"supported_voices":["eve","ara","rex","sal","leo"]},
{"id":"fish-audio/s1","name":"Fish Audio: S1","created":1788000000,"context_length":0,
"architecture":{"input_modalities":["text"],"output_modalities":["speech"]},
"pricing":{"prompt":"0.000015","completion":"0"},"supported_parameters":[],
"supported_voices":null},
{"id":"odd/voices","created":1700000000,
"architecture":{"output_modalities":["speech"]},
"supported_voices":["Kore",""," Puck ",7,null,{"name":"Zephyr"}]},
{"id":"odd/voice-list","created":1600000000,
"architecture":{"output_modalities":["speech"]},"supported_voices":"alloy"},
{"id":"google/gemini-3.5-flash","created":1779000000,
"architecture":{"output_modalities":["text"]},"supported_voices":null}
]}"#;
fn gateway() -> Vec<CatalogModel> {
parse(CatalogueShape::OpenRouter, GATEWAY_BODY).expect("a catalogue")
}
#[test]
fn the_gateway_says_which_models_speak_and_in_which_voices() {
let listed = parse(CatalogueShape::OpenRouterSpeech, GATEWAY_SPEECH_BODY).expect("a list");
let voices = |id: &str| named(&listed, id).voices.join(" ");
assert_eq!(voices("x-ai/grok-voice-tts-1.0"), "eve ara rex sal leo");
assert_eq!(voices("fish-audio/s1"), "");
assert_eq!(voices("odd/voices"), "Kore Puck");
assert_eq!(voices("odd/voice-list"), "");
assert_eq!(
named(&listed, "x-ai/grok-voice-tts-1.0").role,
ModelRole::Speech
);
let offered = for_slot(listed.clone(), ModelSlot::Speech);
assert_eq!(
ids(&offered),
[
"fish-audio/s1",
"x-ai/grok-voice-tts-1.0",
"odd/voices",
"odd/voice-list"
],
"newest first, and the chat model is not a speech model"
);
for slot in [
ModelSlot::Assistant,
ModelSlot::Impersonation,
ModelSlot::Embedder,
] {
let others = for_slot(listed.clone(), slot);
assert!(
others.iter().all(|m| m.role != ModelRole::Speech),
"{slot:?}: {:?}",
ids(&others)
);
}
let unstated = for_slot(gateway(), ModelSlot::Speech);
assert_eq!(ids(&unstated), ["odd/shapes", "odd/window"]);
assert!(gateway().iter().all(|m| m.voices.is_empty()));
}
#[test]
fn the_video_list_is_what_claims_video_with_gemini_first() {
let takes = |id: &str| named(&gateway(), id).facts.video;
assert_eq!(takes("google/gemini-3.5-flash"), Some(true));
assert_eq!(takes("anthropic/claude-haiku-4.5"), Some(false));
assert_eq!(takes("qwen/qwen3.6-27b"), None, "it lists no inputs");
assert_eq!(takes("odd/shapes"), None, "not a list of inputs");
let listed = parse(
CatalogueShape::OpenRouterVideo,
r#"{"data":[
{"id":"qwen/qwen3.6-flash","created":5,"architecture":
{"input_modalities":["text","image","video"],"output_modalities":["text"]}},
{"id":"google/gemini-3.5-flash","created":4,"architecture":
{"input_modalities":["text","video"],"output_modalities":["text"]}},
{"id":"anthropic/claude-haiku-4.5","created":3,"architecture":
{"input_modalities":["text","image"],"output_modalities":["text"]}},
{"id":"~google/gemini-flash-latest","created":2,"architecture":
{"input_modalities":["video","text"],"output_modalities":["text"]}},
{"id":"vendor/says-nothing-of-inputs","created":1,"architecture":
{"input_modalities":[],"output_modalities":["text"]}},
{"id":"vendor/films","created":6,"architecture":
{"input_modalities":["text","video"],"output_modalities":["video"]}},
{"id":"google/gemini-3.5-flash:batch","created":7,"architecture":
{"input_modalities":["text","video"],"output_modalities":["text"]}}
]}"#,
)
.expect("a list");
assert_eq!(
ids(&for_slot(listed.clone(), ModelSlot::Video)),
[
"google/gemini-3.5-flash",
"~google/gemini-flash-latest",
"qwen/qwen3.6-flash",
"vendor/says-nothing-of-inputs"
]
);
assert_eq!(for_slot(listed, ModelSlot::Assistant).len(), 5);
}
#[test]
fn what_a_model_takes_is_not_a_fact_a_row_shows() {
let only_inputs = ModelFacts {
video: Some(true),
..ModelFacts::default()
};
assert!(only_inputs.is_empty());
let windowed = ModelFacts {
context_length: Some(8192),
..only_inputs
};
assert!(!windowed.is_empty());
assert!(ModelFacts::default().is_empty());
}
#[test]
fn a_voice_is_an_entry_with_a_name_and_nothing_else() {
let voice = CatalogModel::voice("Kore");
assert_eq!(voice.id, "Kore");
assert_eq!(voice.role, ModelRole::Speech);
assert!(voice.facts.is_empty() && voice.voices.is_empty());
assert_eq!((voice.display, voice.retiring), (None, None));
}
fn ids(models: &[CatalogModel]) -> Vec<&str> {
models.iter().map(|m| m.id.as_str()).collect()
}
fn named<'a>(models: &'a [CatalogModel], id: &str) -> &'a CatalogModel {
let found = models.iter().find(|m| m.id == id);
found.unwrap_or_else(|| panic!("{id} is not listed in {:?}", ids(models)))
}
#[test]
fn the_gateway_lists_everything_but_batch_newest_first() {
let newest_first = "google/gemini-3.5-flash qwen/qwen3.6-27b openai/gpt-image-2 \
anthropic/claude-haiku-4.5 meta-llama/llama-3.3-70b-instruct:free \
openai/gpt-4-turbo odd/shapes openrouter/auto odd/window";
assert_eq!(
ids(&gateway()),
newest_first.split_whitespace().collect::<Vec<_>>()
);
}
#[test]
fn the_gateway_says_what_a_model_is_for_by_what_it_answers_with() {
let models = gateway();
let role = |id: &str| named(&models, id).role;
assert_eq!(role("anthropic/claude-haiku-4.5"), ModelRole::Chat);
assert_eq!(role("openai/gpt-image-2"), ModelRole::Other);
assert_eq!(role("odd/shapes"), ModelRole::Unstated);
assert_eq!(role("odd/window"), ModelRole::Unstated);
let embedders = parse(
CatalogueShape::OpenRouterEmbeddings,
GATEWAY_EMBEDDINGS_BODY,
)
.expect("a list");
assert_eq!(
ids(&embedders),
["baai/bge-m3", "openai/text-embedding-3-small"]
);
assert!(embedders.iter().all(|m| m.role == ModelRole::Embedding));
let chat = for_slot(models.clone(), ModelSlot::Assistant);
assert!(
!ids(&chat).contains(&"openai/gpt-image-2") && chat.len() == models.len() - 1,
"what answers with images alone is not offered for chat: {:?}",
ids(&chat)
);
assert_eq!(chat, for_slot(models.clone(), ModelSlot::Impersonation));
assert_eq!(
ids(&for_slot(models, ModelSlot::Embedder)),
["odd/shapes", "odd/window"],
"a chat model is not offered as an embedder; silence still is"
);
assert_eq!(
for_slot(embedders.clone(), ModelSlot::Embedder),
embedders,
"the embedder's row gets the embedding list whole, in its order"
);
assert!(for_slot(embedders, ModelSlot::Assistant).is_empty());
}
const GATEWAY_FACTS: &str = "
anthropic/claude-haiku-4.5 200000 1000000 5000000 yes | $1 in, $5 out, as published
google/gemini-3.5-flash 1048576 300000 2500000 yes | thirty cents in, two and a half dollars out
qwen/qwen3.6-27b 131072 120000 480000 yes | rounded, not cut: neither is 119999 or 479999
meta-llama/llama-3.3-70b-instruct:free 65536 0 0 no | free is a price, and no tools is a claim
openai/gpt-4-turbo 128000 10000000 30000000 - | a price sent as a number; no parameter list
openrouter/auto 2000000 - - yes | a router prices itself -1: it depends
openai/gpt-image-2 32000 5000000 - - | half a price is still that half
odd/shapes - - - - | unreadable fields, an empty parameter list
odd/window - - - - | a window of zero is no window
";
#[test]
fn the_gateways_window_price_and_tools_are_read_as_published() {
let models = gateway();
let rows: Vec<&str> = GATEWAY_FACTS
.lines()
.filter(|l| !l.trim().is_empty())
.collect();
assert_eq!(rows.len(), models.len(), "every entry has its row");
let mut wrong = Vec::new();
for row in rows {
let (columns, why) = row.split_once('|').unwrap();
let c: Vec<&str> = columns.split_whitespace().collect();
let got = named(&models, c[0]).facts;
let want = ModelFacts {
context_length: c[1].parse().ok(),
prompt_price: c[2].parse().ok(),
completion_price: c[3].parse().ok(),
tools: Some(c[4] == "yes").filter(|_| c[4] != "-"),
video: got.video,
};
if got != want {
wrong.push(format!("{} — {}: {got:?}", c[0], why.trim()));
}
}
assert!(wrong.is_empty(), "{wrong:#?}");
}
#[test]
fn the_gateways_name_and_last_day_are_kept_where_it_published_them() {
let models = gateway();
let said = |id: &str| {
let m = named(&models, id);
(m.display.as_deref(), m.retiring.as_deref())
};
assert_eq!(
said("anthropic/claude-haiku-4.5"),
(Some("Anthropic: Claude Haiku 4.5"), None)
);
assert_eq!(
said("openai/gpt-4-turbo"),
(Some("OpenAI: GPT-4 Turbo"), Some("2026-11-01"))
);
assert_eq!(
said("google/gemini-3.5-flash").1,
None,
"an empty date is no date"
);
assert_eq!(said("openai/gpt-image-2").0, None, "an empty name is none");
assert_eq!(said("odd/window"), (None, None));
}
#[test]
fn the_gateways_route_follows_the_key_and_the_slot() {
let url = |shape, key: Option<&str>| {
CatalogueRequest {
shape,
base: "https://openrouter.ai/api/v1/".to_string(),
key: key.map(str::to_string),
attribution: false,
}
.url()
};
assert_eq!(
url(CatalogueShape::OpenRouter, Some("k")),
"https://openrouter.ai/api/v1/models/user"
);
assert_eq!(
url(CatalogueShape::OpenRouter, None),
"https://openrouter.ai/api/v1/models"
);
for key in [None, Some("k")] {
assert_eq!(
url(CatalogueShape::OpenRouterEmbeddings, key),
"https://openrouter.ai/api/v1/embeddings/models"
);
}
assert_eq!(
url(CatalogueShape::OpenRouterVideo, Some("k")),
"https://openrouter.ai/api/v1/models/user"
);
assert_eq!(
url(CatalogueShape::OpenRouterVideo, None),
"https://openrouter.ai/api/v1/models?input_modalities=video"
);
assert_eq!(
url(CatalogueShape::OpenRouterSpeech, Some("k")),
"https://openrouter.ai/api/v1/models/user?output_modalities=speech"
);
assert_eq!(
url(CatalogueShape::OpenRouterSpeech, None),
"https://openrouter.ai/api/v1/models?output_modalities=speech"
);
assert_eq!(
url(CatalogueShape::OpenAi, Some("k")),
url(CatalogueShape::OpenAi, None)
);
}
}
#[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,
attribution: false,
})
.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"
);
}
}