oy-cli 0.7.16

Local AI coding CLI for inspecting, editing, running commands, and auditing repositories
Documentation
use crate::config;
use anyhow::{Result, anyhow, bail};
use genai::adapter::AdapterKind;
use genai::resolver::{AuthData, AuthResolver, Endpoint, ServiceTargetResolver};
use genai::{Client, ModelIden, ServiceTarget};
use serde::Serialize;
use std::env;

pub(crate) use super::auth::{AuthStatus, auth_statuses};
pub(crate) use super::endpoints::AdapterModels;
use super::endpoints::{
    env_value, inspect_openai_compatible_models, normalize_base_url, shim_endpoint_config,
};

#[derive(Debug, Clone, Serialize)]
pub struct ModelListing {
    pub current: Option<String>,
    pub current_shim: Option<String>,
    pub auth: Vec<AuthStatus>,
    pub dynamic: Vec<AdapterModels>,
    pub all_models: Vec<String>,
}

pub fn resolve_model(configured: Option<&str>) -> Result<String> {
    if let Some(value) = configured.filter(|v| !v.trim().is_empty()) {
        return Ok(canonical_model_spec(value));
    }
    if let Ok(value) = env::var("OY_MODEL")
        && !value.trim().is_empty()
    {
        return Ok(canonical_model_spec(&value));
    }
    if let Some(model) = config::load_model_config()?.model {
        return Ok(canonical_model_spec(&model));
    }
    bail!(no_model_message())
}

fn no_model_message() -> String {
    let mut lines = vec!["No model configured.".to_string()];
    lines.push("Run `oy model` to inspect auth-backed model endpoints.".to_string());
    lines.push("Then run: oy \"inspect this repo\"".to_string());
    lines.push("Advanced: use `oy model` to list options or set OY_MODEL for one run.".to_string());
    lines.join("\n")
}

pub fn resolve_shim() -> Result<Option<String>> {
    if let Ok(value) = env::var("OY_SHIM")
        && !value.trim().is_empty()
    {
        return Ok(Some(value));
    }
    Ok(config::load_model_config()?.shim)
}

pub async fn inspect_models() -> Result<ModelListing> {
    let current = resolve_model(None).ok();
    let current_shim = resolve_shim().ok().flatten();
    let auth = auth_statuses()
        .into_iter()
        .filter(|item| item.availability.is_available())
        .collect::<Vec<_>>();
    let dynamic = inspect_openai_compatible_models().await;
    let all_models = collect_all_models(&dynamic);
    Ok(ModelListing {
        current,
        current_shim,
        auth,
        dynamic,
        all_models,
    })
}

fn collect_all_models(dynamic: &[AdapterModels]) -> Vec<String> {
    let mut items = dynamic
        .iter()
        .flat_map(|group| group.models().iter().cloned())
        .collect::<Vec<_>>();
    items.sort();
    items.dedup();
    items
}

pub fn canonical_model_spec(spec: &str) -> String {
    spec.trim().to_string()
}

pub fn to_genai_model_spec(spec: &str) -> String {
    canonical_model_spec(spec)
}

pub fn default_reasoning_effort(model_spec: &str) -> Option<&'static str> {
    let (_, model) = config::split_model_spec(model_spec);
    let (inline_effort, _) = split_reasoning_effort_suffix(model);
    inline_effort.or_else(|| reasoning_effort_option(model_spec))
}

pub fn reasoning_effort_option(model_spec: &str) -> Option<&'static str> {
    if env::var("OY_THINKING").is_ok() || env::var("OY_REASONING_EFFORT").is_ok() {
        return configured_reasoning_effort();
    }
    let (_, model) = config::split_model_spec(model_spec);
    let (inline_effort, base_model) = split_reasoning_effort_suffix(model);
    if inline_effort.is_some() {
        return None;
    }
    reasoning_capable_model(base_model).then_some("high")
}

fn configured_reasoning_effort() -> Option<&'static str> {
    env_value("OY_THINKING")
        .or_else(|| env_value("OY_REASONING_EFFORT"))
        .and_then(|value| match value.trim().to_ascii_lowercase().as_str() {
            "" | "auto" => None,
            "off" | "false" | "0" | "none" => Some("none"),
            "minimal" => Some("minimal"),
            "low" => Some("low"),
            "medium" => Some("medium"),
            "high" | "true" | "1" | "on" => Some("high"),
            _ => None,
        })
}

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

fn reasoning_capable_model(model: &str) -> bool {
    let model = model
        .rsplit_once('/')
        .map(|(_, name)| name)
        .unwrap_or(model)
        .to_ascii_lowercase();
    model.starts_with("gpt-5")
        || model.contains("codex")
        || model.starts_with("o1")
        || model.starts_with("o3")
        || model.starts_with("o4")
        || model.starts_with("claude-3-7")
        || model.starts_with("claude-4")
        || model.starts_with("claude-sonnet-4")
        || model.starts_with("claude-opus-4")
        || model.starts_with("gemini-3")
}

pub fn build_client() -> Result<Client> {
    let mut builder = Client::builder();
    if let Some(resolver) = service_target_resolver()? {
        builder = builder.with_service_target_resolver(resolver);
    }
    if let Some(auth) = auth_resolver()? {
        builder = builder.with_auth_resolver(auth);
    }
    Ok(builder.build())
}

fn auth_resolver() -> Result<Option<AuthResolver>> {
    let Some(api_key) = env_value("OPENAI_API_KEY") else {
        return Ok(None);
    };
    let resolver = AuthResolver::from_resolver_fn(move |model: ModelIden| {
        if openai_env_applies_to_model(&model) {
            Ok(Some(AuthData::from_single(api_key.clone())))
        } else {
            Ok(None)
        }
    });
    Ok(Some(resolver))
}

fn openai_adapter_for_model(model: &str) -> AdapterKind {
    if config::is_openai_responses_model(model) {
        AdapterKind::OpenAIResp
    } else {
        AdapterKind::OpenAI
    }
}

fn is_openai_adapter(kind: AdapterKind) -> bool {
    matches!(kind, AdapterKind::OpenAI | AdapterKind::OpenAIResp)
}

fn openai_env_applies_to_model(model: &ModelIden) -> bool {
    is_openai_adapter(model.adapter_kind) && openai_env_applies_to_model_name(&model.model_name)
}

fn openai_env_applies_to_model_name(model_name: &str) -> bool {
    let (namespace, _) = config::split_model_spec(model_name);
    matches!(namespace, None | Some("openai_resp")) && env_value("OY_SHIM").is_none()
}

fn service_target_resolver() -> Result<Option<ServiceTargetResolver>> {
    let base_url = env_value("OPENAI_BASE_URL");
    let configured_shim = resolve_shim()?;
    let resolver = ServiceTargetResolver::from_resolver_fn(move |target: ServiceTarget| {
        let model_name = target.model.model_name.to_string();
        if let Some(mapped) = openai_compatible_target(&target.model, configured_shim.as_deref())
            .map_err(|err| err.to_string())?
        {
            return Ok(mapped);
        }
        if let Some(url) = base_url.as_ref().filter(|_| configured_shim.is_none())
            && openai_env_applies_to_model(&target.model)
        {
            return Ok(ServiceTarget {
                endpoint: Endpoint::from_owned(normalize_base_url(url) + "/"),
                auth: target.auth,
                model: ModelIden::new(openai_adapter_for_model(&model_name), model_name),
            });
        }
        Ok(target)
    });
    Ok(Some(resolver))
}

fn openai_compatible_target(
    model: &ModelIden,
    configured_shim: Option<&str>,
) -> Result<Option<ServiceTarget>> {
    let model_name = model.model_name.to_string();
    let (namespace, inline_model) = config::split_model_spec(&model_name);
    let inline_shim = namespace.filter(|shim| config::is_routing_shim(shim));
    let shim = inline_shim.or(configured_shim);
    let Some(shim) = shim else {
        return Ok(None);
    };
    if !config::is_routing_shim(shim) {
        bail!("invalid routing shim: {shim}");
    }
    let target_model = if inline_shim.is_some() {
        ModelIden::new(
            openai_adapter_for_model(inline_model),
            inline_model.to_string(),
        )
    } else {
        model.clone()
    };
    let config = shim_endpoint_config(shim)
        .ok_or_else(|| anyhow!("routing shim {shim} is not configured or lacks credentials"))?;
    Ok(Some(ServiceTarget {
        endpoint: Endpoint::from_owned(normalize_base_url(&config.base_url) + "/"),
        auth: AuthData::from_single(config.api_key),
        model: target_model,
    }))
}

#[cfg(test)]
pub(crate) static ENV_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn openai_response_only_models_use_responses_adapter() {
        assert_eq!(openai_adapter_for_model("gpt-5.5"), AdapterKind::OpenAIResp);
        assert_eq!(
            openai_adapter_for_model("openai/gpt-5.5"),
            AdapterKind::OpenAIResp
        );
        assert_eq!(
            openai_adapter_for_model("gpt-4.1-mini"),
            AdapterKind::OpenAI
        );
    }

    #[test]
    fn model_listing_only_includes_introspected_models() {
        let models = collect_all_models(&[]);
        assert!(models.is_empty());
    }

    #[test]
    fn genai_model_spec_is_identity() {
        assert_eq!(to_genai_model_spec("copilot::gpt-5.5"), "copilot::gpt-5.5");
        assert_eq!(to_genai_model_spec("gpt-5.4-mini"), "gpt-5.4-mini");
        assert_eq!(
            canonical_model_spec("  local-8080::qwen3.5  "),
            "local-8080::qwen3.5"
        );
    }

    #[test]
    fn reasoning_defaults_to_high_for_capable_models_and_allows_suffix_override() {
        let _guard = ENV_TEST_LOCK.lock().unwrap_or_else(|err| err.into_inner());
        unsafe { std::env::remove_var("OY_THINKING") };
        unsafe { std::env::remove_var("OY_REASONING_EFFORT") };
        assert_eq!(default_reasoning_effort("gpt-5.5"), Some("high"));
        assert_eq!(
            default_reasoning_effort("copilot::gpt-5.5-low"),
            Some("low")
        );
        assert_eq!(default_reasoning_effort("gpt-4.1-mini"), None);
    }

    #[test]
    fn reasoning_env_override_can_disable_or_adjust() {
        let _guard = ENV_TEST_LOCK.lock().unwrap_or_else(|err| err.into_inner());
        unsafe { std::env::set_var("OY_THINKING", "off") };
        assert_eq!(default_reasoning_effort("gpt-5.5"), Some("none"));
        unsafe { std::env::set_var("OY_THINKING", "medium") };
        assert_eq!(default_reasoning_effort("gpt-5.5"), Some("medium"));
        unsafe { std::env::remove_var("OY_THINKING") };
        unsafe { std::env::remove_var("OY_REASONING_EFFORT") };
    }

    #[test]
    fn inline_routing_shim_overrides_configured_shim() {
        let _guard = ENV_TEST_LOCK.lock().unwrap_or_else(|err| err.into_inner());
        unsafe { std::env::set_var("LOCAL_API_KEY", "local-token") };
        let target = ModelIden::new(AdapterKind::OpenAI, "local-8088::qwen3.5".to_string());
        let mapped = openai_compatible_target(&target, Some("openai"))
            .unwrap()
            .unwrap();
        assert_eq!(mapped.model.model_name, "qwen3.5");
        assert_eq!(mapped.endpoint.base_url(), "http://127.0.0.1:8088/v1/");
        unsafe { std::env::remove_var("LOCAL_API_KEY") };
    }

    #[test]
    fn native_adapter_namespace_is_not_treated_as_routing_shim() {
        let _guard = ENV_TEST_LOCK.lock().unwrap_or_else(|err| err.into_inner());
        unsafe { std::env::set_var("LOCAL_API_KEY", "local-token") };
        let target = ModelIden::new(AdapterKind::OpenAIResp, "openai_resp::gpt-5.5".to_string());
        assert!(openai_compatible_target(&target, None).unwrap().is_none());

        let mapped = openai_compatible_target(&target, Some("local-8088"))
            .unwrap()
            .unwrap();
        assert_eq!(mapped.model.model_name, "openai_resp::gpt-5.5");
        assert_eq!(mapped.model.adapter_kind, AdapterKind::OpenAIResp);
        assert_eq!(mapped.endpoint.base_url(), "http://127.0.0.1:8088/v1/");
        unsafe { std::env::remove_var("LOCAL_API_KEY") };
    }

    #[test]
    fn configured_shim_still_routes_plain_model() {
        let _guard = ENV_TEST_LOCK.lock().unwrap_or_else(|err| err.into_inner());
        unsafe { std::env::set_var("LOCAL_API_KEY", "local-token") };
        let target = ModelIden::new(AdapterKind::OpenAI, "qwen3.5".to_string());
        let mapped = openai_compatible_target(&target, Some("local-8088"))
            .unwrap()
            .unwrap();
        assert_eq!(mapped.model.model_name, "qwen3.5");
        assert_eq!(mapped.endpoint.base_url(), "http://127.0.0.1:8088/v1/");
        unsafe { std::env::remove_var("LOCAL_API_KEY") };
    }

    #[test]
    fn openai_env_only_applies_to_openai_models_without_routing() {
        let _guard = ENV_TEST_LOCK.lock().unwrap_or_else(|err| err.into_inner());
        unsafe { std::env::remove_var("OY_SHIM") };
        assert!(openai_env_applies_to_model(&ModelIden::new(
            AdapterKind::OpenAI,
            "gpt-4.1-mini"
        )));
        assert!(openai_env_applies_to_model(&ModelIden::new(
            AdapterKind::OpenAIResp,
            "gpt-5.5"
        )));
        assert!(openai_env_applies_to_model(&ModelIden::new(
            AdapterKind::OpenAIResp,
            "openai_resp::gpt-5.5"
        )));
        assert!(!openai_env_applies_to_model(&ModelIden::new(
            AdapterKind::Gemini,
            "gemini-2.5-flash"
        )));
        assert!(!openai_env_applies_to_model(&ModelIden::new(
            AdapterKind::Anthropic,
            "claude-sonnet-4"
        )));
        assert!(!openai_env_applies_to_model(&ModelIden::new(
            AdapterKind::OpenAI,
            "openai::gpt-4.1-mini"
        )));
        unsafe { std::env::set_var("OY_SHIM", "openai") };
        assert!(!openai_env_applies_to_model(&ModelIden::new(
            AdapterKind::OpenAI,
            "gpt-4.1-mini"
        )));
        unsafe { std::env::remove_var("OY_SHIM") };
    }
}