use std::time::Duration;
use serde::{Deserialize, Serialize};
use super::{ClientError, ClientKind, ClientManager, compact_body, normalize_base_url};
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
pub struct RouterModel {
pub id: String,
#[serde(default)]
pub owned_by: String,
#[serde(default)]
pub default_reasoning_level: Option<String>,
#[serde(default)]
pub supported_reasoning_levels: Option<Vec<RouterReasoningLevel>>,
#[serde(default)]
pub client_capabilities: RouterClientCapabilities,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
pub struct RouterClientCapabilities {
#[serde(default)]
pub claude: Option<ClaudeModelCapabilities>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
pub struct ClaudeModelCapabilities {
pub behaves_as: String,
pub source: String,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ClaudeCapabilityProfile {
behaves_as: &'static str,
source: &'static str,
}
impl ClaudeCapabilityProfile {
#[must_use]
pub(crate) const fn behaves_as(self) -> &'static str {
self.behaves_as
}
#[must_use]
pub(crate) const fn source(self) -> &'static str {
self.source
}
}
#[must_use]
pub fn claude_capability_profile(owner: &str) -> Option<ClaudeCapabilityProfile> {
(owner == super::ZAI_MODEL_OWNER).then_some(ClaudeCapabilityProfile {
behaves_as: "claude-sonnet-4-5",
source: "provider-protocol:z.ai-anthropic",
})
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct RouterReasoningLevel {
pub effort: String,
#[serde(default)]
pub description: String,
}
fn apply_codex_reasoning_profile(model: &mut RouterModel) {
let Some(profile) = super::codex_reasoning_profile(&model.owned_by) else {
return;
};
if model.supported_reasoning_levels.is_none()
&& model
.default_reasoning_level
.as_deref()
.is_none_or(|default| profile.supports(default))
{
model.supported_reasoning_levels = Some(profile.levels());
}
if model.default_reasoning_level.is_none()
&& model
.supported_reasoning_levels
.as_ref()
.is_some_and(|levels| levels.iter().any(|level| level.effort == profile.default()))
{
model.default_reasoning_level = Some(profile.default().to_string());
}
}
pub(super) fn apply_codex_reasoning_profiles(client: ClientKind, models: &mut [RouterModel]) {
if client == ClientKind::Codex {
for model in models {
apply_codex_reasoning_profile(model);
}
}
}
#[derive(Deserialize)]
struct RouterCatalog {
data: Vec<RouterModel>,
}
impl ClientManager {
pub(crate) async fn catalog(
&self,
client: ClientKind,
base_url: &str,
token: &str,
) -> Result<Vec<RouterModel>, ClientError> {
self.catalog_with_client(&reqwest::Client::new(), client, base_url, token)
.await
}
pub(crate) async fn catalog_with_client(
&self,
http: &reqwest::Client,
client: ClientKind,
base_url: &str,
token: &str,
) -> Result<Vec<RouterModel>, ClientError> {
let base_url = normalize_base_url(base_url)?;
let url = models_url(client, &base_url);
let request = http
.get(&url)
.header("x-link-assistant-client", client.canonical_name());
let request = match client {
ClientKind::GeminiCli => request.header("x-goog-api-key", token),
_ => request.bearer_auth(token),
};
let response = request
.timeout(Duration::from_secs(15))
.send()
.await
.map_err(|error| {
ClientError::message(format!("router catalog is not reachable at {url}: {error}"))
})?;
let code = response.status();
let response_body = response.text().await.unwrap_or_default();
if !code.is_success() {
return Err(ClientError::message(format!(
"router catalog request failed at {url} ({code}): {}",
compact_body(&response_body)
)));
}
let catalog: RouterCatalog = serde_json::from_str(&response_body).map_err(|error| {
ClientError::message(format!("router returned an invalid model catalog: {error}"))
})?;
let mut models = catalog
.data
.into_iter()
.filter(|model| !model.id.trim().is_empty())
.collect::<Vec<_>>();
apply_codex_reasoning_profiles(client, &mut models);
models.sort_by(|left, right| {
left.id
.cmp(&right.id)
.then_with(|| left.owned_by.cmp(&right.owned_by))
});
models.dedup_by(|left, right| left.id == right.id && left.owned_by == right.owned_by);
if models.is_empty() {
return Err(ClientError::message(
"router catalog contains no models from healthy subscriptions",
));
}
Ok(models)
}
}
fn models_url(_client: ClientKind, base_url: &str) -> String {
let base_url = base_url.trim_end_matches('/');
let origin = [
"/api/services/anthropic",
"/api/services/openai/v1",
"/api/services/codex/v1",
"/api/services/qwen/v1",
"/api/services/gemini",
"/api/gemini",
"/api/qwen/v1",
"/api/codex/v1",
"/v1",
]
.into_iter()
.find_map(|suffix| base_url.strip_suffix(suffix))
.unwrap_or(base_url);
format!(
"{origin}{}",
crate::route_contract::route_template(crate::route_contract::RouteId::AggregateModels)
)
}
pub(super) fn doctor_model(
client: ClientKind,
catalog: &[RouterModel],
) -> Result<&str, ClientError> {
select_model(client, catalog).ok_or_else(|| ClientError::message(unavailable(client, catalog)))
}
#[must_use]
pub fn select_model(client: ClientKind, catalog: &[RouterModel]) -> Option<&str> {
let integration = client.integration();
for owner in integration.model_owners {
if let Some(model) = catalog.iter().find(|model| &model.owned_by == owner) {
return Some(model.id.as_str());
}
}
if integration.strict_owner && !catalog.iter().all(|model| model.owned_by.is_empty()) {
return None;
}
catalog.first().map(|model| model.id.as_str())
}
#[must_use]
pub fn claude_gateway_model(catalog: &[RouterModel], explicit: Option<&str>) -> Option<String> {
if let Some(explicit) = explicit
&& catalog
.iter()
.any(|model| model.id == explicit && model.owned_by == super::ZAI_MODEL_OWNER)
{
return Some(explicit.to_string());
}
if catalog
.iter()
.any(|model| model.owned_by == super::ANTHROPIC_MODEL_OWNER)
{
return None;
}
catalog
.iter()
.find(|model| model.owned_by == super::ZAI_MODEL_OWNER)
.map(|model| model.id.clone())
}
#[must_use]
pub fn usable_models(client: ClientKind, catalog: &[RouterModel]) -> Vec<RouterModel> {
let integration = client.integration();
if integration.model_owners.is_empty() {
return catalog.to_vec();
}
let mut preferred: Vec<RouterModel> = Vec::new();
for owner in integration.model_owners {
preferred.extend(
catalog
.iter()
.filter(|model| &model.owned_by == owner)
.cloned(),
);
}
if preferred.is_empty() && !integration.strict_owner {
return catalog.to_vec();
}
preferred
}
#[must_use]
pub fn unavailable(client: ClientKind, catalog: &[RouterModel]) -> String {
let mut advertised: Vec<&str> = catalog
.iter()
.map(|model| model.owned_by.as_str())
.filter(|owner| !owner.is_empty())
.collect();
advertised.sort_unstable();
advertised.dedup();
let holdings = if advertised.is_empty() {
"the catalog is empty".to_string()
} else {
format!("it advertises only {} models", advertised.join(", "))
};
let wanted = client.integration().model_owners.join(", ");
format!(
"the router advertises no model for {} ({wanted} models): {holdings}. Authorize a \
matching subscription on the router host, or pass --model explicitly to use one of \
the models it does advertise",
client.integration().name
)
}