use crate::canonical::{select_model, Provenance};
use crate::config::errors::ConfigError;
use crate::config::partial::{or_map, OutMode, PartialConfig, PartialProvider};
use crate::config::resolved::ResolvedConfig;
use crate::store::ModelCache;
mod ingress;
mod row;
pub(crate) use ingress::IngressConfig;
impl PartialConfig {
pub fn into_resolved(
self,
req_model: Option<&str>,
cache: Option<&dyn ModelCache>,
) -> Result<ResolvedConfig, ConfigError> {
self.check_scalars()?;
let routing_model = req_model
.or(self.model.as_deref())
.filter(|m| !m.is_empty());
let (name, mut partial) = self.route(routing_model, cache)?;
partial.base_url = self.base_url.or(partial.base_url);
let mut bd = std::mem::take(&mut partial.body_defaults);
let max_tokens = self.max_tokens.or(row::take_u32(&mut bd, "max_tokens")?);
let temperature = self.temperature.or(row::take_f32(&mut bd, "temperature")?);
let top_p = self.top_p.or(row::take_f32(&mut bd, "top_p")?);
let stream = self.stream.or(row::take_bool(&mut bd, "stream")?);
let extra = or_map(bd, self.extra);
let provider = row::complete(name, partial)?;
let model = match routing_model {
Some(m) => provider
.model_aliases
.get(m)
.cloned()
.unwrap_or_else(|| m.to_owned()),
None => String::new(),
};
Ok(ResolvedConfig {
provider,
model,
model_from_cache: false,
output: self.output.unwrap_or(OutMode::Text),
thinking: self.thinking.unwrap_or(false),
inline_key: self.api_key,
max_tokens,
temperature,
top_p,
reasoning: self.reasoning,
stream,
timeout: self.timeout,
system: self.system,
extra,
})
}
pub(crate) fn into_rows(self) -> Result<Vec<crate::config::provider::Provider>, ConfigError> {
self.providers
.into_iter()
.map(|(name, row)| row::complete(name, row))
.collect()
}
fn check_scalars(&self) -> Result<(), ConfigError> {
if self.max_tokens == Some(0) {
return Err(bad("max_tokens", "must be greater than zero"));
}
if self.temperature.is_some_and(f32::is_nan) {
return Err(bad("temperature", "must be a number"));
}
if self.top_p.is_some_and(f32::is_nan) {
return Err(bad("top_p", "must be a number"));
}
self.check_prefixes()
}
fn check_prefixes(&self) -> Result<(), ConfigError> {
for (name, row) in &self.providers {
let empty = row
.model_prefixes
.as_ref()
.is_some_and(|ps| ps.iter().any(String::is_empty));
if empty {
return Err(bad(
"model_prefixes",
&format!("provider `{name}`: an empty prefix would own every model"),
));
}
}
Ok(())
}
fn route(
&self,
routing_model: Option<&str>,
cache: Option<&dyn ModelCache>,
) -> Result<(String, PartialProvider), ConfigError> {
if let Some(name) = &self.provider {
let row = self
.row(name)
.ok_or_else(|| ConfigError::UnknownProvider { name: name.clone() })?;
return Ok((name.clone(), row.clone()));
}
let hit = match routing_model {
None => self.providers.first(),
Some(model) => self
.providers
.iter()
.find(|(_, row)| row_claims(row, model))
.or_else(|| {
self.providers
.iter()
.find(|(name, _)| cache_places(cache, name, model))
}),
};
hit.map(|(name, row)| (name.clone(), row.clone()))
.ok_or(ConfigError::NoProvider)
}
}
fn cache_places(cache: Option<&dyn ModelCache>, provider: &str, model: &str) -> bool {
let cached = cache.and_then(|c| c.get(provider)).unwrap_or_default();
matches!(
select_model(&cached, model, provider),
Ok((_, Provenance::Cached))
)
}
fn row_claims(row: &PartialProvider, model: &str) -> bool {
let aliased = row
.model_aliases
.as_ref()
.is_some_and(|a| a.contains_key(model));
let prefixed = row
.model_prefixes
.as_ref()
.is_some_and(|ps| ps.iter().any(|p| model.starts_with(p.as_str())));
aliased || prefixed
}
fn bad(key: &str, detail: &str) -> ConfigError {
ConfigError::BadValue {
key: key.to_owned(),
detail: detail.to_owned(),
}
}