oy-cli 0.10.4

Local AI coding CLI for inspecting, editing, running commands, and auditing repositories
Documentation
//! Model-spec to [`ModelRoute`] resolution.
//!
//! This mirrors OpenCode's split: agent/app code chooses a model spec, while
//! the LLM layer maps provider/model/auth/options into a runnable route.

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)
        }
    }
}