use anyhow::{Result, bail};
use crate::config;
use crate::llm::{merge_json_objects, ModelRoute, Protocol};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct ParsedModelSpec<'a> {
pub(crate) provider: Option<&'a str>,
pub(crate) model: &'a str,
pub(crate) base_model: &'a str,
pub(crate) reasoning_effort: Option<&'static str>,
}
impl<'a> ParsedModelSpec<'a> {
pub(crate) fn parse(spec: &'a str) -> Self {
let spec = spec.trim();
let (namespace, model) = config::split_model_spec(spec);
if let Some(provider) = namespace {
return Self::from_parts(Some(provider), model);
}
if let Some((provider, model)) = spec.split_once('/')
&& !provider.trim().is_empty()
&& !model.trim().is_empty()
{
return Self::from_parts(Some(provider), model);
}
Self::from_parts(None, model)
}
fn from_parts(provider: Option<&'a str>, model: &'a str) -> Self {
let (reasoning_effort, base_model) = split_reasoning_effort_suffix(model);
Self {
provider,
model,
base_model,
reasoning_effort,
}
}
pub(crate) fn provider_or_openai(self) -> &'a str {
self.provider
.map(config::canonical_provider)
.unwrap_or("openai")
}
}
fn split_reasoning_effort_suffix(model: &str) -> (Option<&'static str>, &str) {
if let Some((base, suffix)) = model.rsplit_once('-') {
let effort = match suffix.to_ascii_lowercase().as_str() {
"none" => Some("none"),
"minimal" => Some("minimal"),
"low" => Some("low"),
"medium" => Some("medium"),
"high" => Some("high"),
_ => None,
};
if let Some(effort) = effort {
return (Some(effort), base);
}
}
(None, model)
}
pub(crate) fn model_route(
model_spec: &str,
reasoning_effort: Option<String>,
) -> Result<ModelRoute> {
let parsed = ParsedModelSpec::parse(model_spec);
let requested_provider = parsed.provider_or_openai();
let provider_metadata = crate::llm::providers::provider_metadata(requested_provider);
let provider = crate::llm::providers::canonical_provider_id(requested_provider);
ensure_supported_provider(provider, provider_metadata)?;
let mut route =
crate::llm::providers::route::prepare_chat(provider, provider_metadata, parsed.base_model)?;
let provider_defaults = crate::llm::providers::default_provider_options(
provider_metadata,
&route.model,
route.protocol,
);
let reasoning_overlay = if matches!(
route.protocol,
Protocol::AnthropicMessages | Protocol::Gemini
) {
None
} else {
reasoning_effort_json(reasoning_effort, route.protocol.uses_responses_api())
};
let route_params = route.additional_params.take();
route.additional_params = merge_additional_params(
merge_additional_params(provider_defaults, route_params),
reasoning_overlay,
);
Ok(route)
}
fn ensure_supported_provider(
provider: &str,
metadata: Option<crate::llm::providers::ProviderMetadata>,
) -> Result<()> {
let Some(metadata) = metadata else {
return Ok(());
};
if metadata.supported {
return Ok(());
}
bail!(
"provider `{provider}` uses {:?}, which is not implemented by oy's native LLM backend yet",
metadata.family
)
}
pub(crate) fn reasoning_effort_json(
effort: Option<String>,
responses_api: bool,
) -> Option<serde_json::Value> {
let effort = effort?;
if responses_api {
Some(serde_json::json!({"reasoning": {"effort": effort}}))
} else {
Some(serde_json::json!({"reasoning_effort": effort}))
}
}
pub(crate) fn merge_additional_params(
base: Option<serde_json::Value>,
overlay: Option<serde_json::Value>,
) -> Option<serde_json::Value> {
match (base, overlay) {
(None, None) => None,
(Some(value), None) | (None, Some(value)) => Some(value),
(Some(mut base), Some(overlay)) => {
merge_json_objects(&mut base, overlay);
Some(base)
}
}
}