sim-lib-agent 0.1.6

Agent runtime surfaces for SIM.
Documentation
use super::super::options::parse_component_options;
use super::ai_provider_runner::provider_runner_value;
use crate::{
    AI_RUNNER_CAPABILITY, AI_RUNNER_LOCAL_CAPABILITY, AI_RUNNER_NETWORK_CAPABILITY,
    AI_RUNNER_SECRET_CAPABILITY,
};
use sim_kernel::{Args, CapabilityName, Cx, Error, Expr, NumberLiteral, Result, Symbol, Value};
use sim_lib_agent_runner_http::{
    HttpProbeTransport, ProviderAuth, ProviderConfig, ProviderProfile, probe_provider,
    provider_profiles,
};
use std::collections::HashMap;

pub(crate) fn provider_profiles_value(cx: &mut Cx, args: Args) -> Result<Value> {
    if !args.values().is_empty() {
        return Err(Error::Eval(
            "provider/profiles does not accept arguments".to_owned(),
        ));
    }
    crate::value_from_expr(cx, &provider_profiles_expr())
}

pub(crate) fn provider_probe_value(cx: &mut Cx, args: Args) -> Result<Value> {
    let options = parse_component_options(cx, args, "provider/probe")?;
    let profile = provider_profile_option(cx, &options)?;
    let config = ProviderConfig::from_options(profile, cx, &options)?;
    require_provider_probe_capabilities(cx, &config)?;
    let report = probe_provider(&HttpProbeTransport, &config)?;
    crate::value_from_expr(cx, &report.to_expr())
}

pub(crate) fn runner_openai_value(cx: &mut Cx, args: Args) -> Result<Value> {
    let options = parse_component_options(cx, args, "runner/openai")?;
    let config = ProviderConfig::from_options(provider_profiles::openai(), cx, &options)?;
    provider_runner_value(cx, config)
}

pub(crate) fn runner_anthropic_value(cx: &mut Cx, args: Args) -> Result<Value> {
    let options = parse_component_options(cx, args, "runner/anthropic")?;
    let config = ProviderConfig::from_options(provider_profiles::anthropic(), cx, &options)?;
    provider_runner_value(cx, config)
}

pub(crate) fn runner_lm_studio_value(cx: &mut Cx, args: Args) -> Result<Value> {
    let options = parse_component_options(cx, args, "runner/lm-studio")?;
    let config = ProviderConfig::from_options(provider_profiles::lm_studio(), cx, &options)?;
    provider_runner_value(cx, config)
}

pub(crate) fn runner_lemonade_value(cx: &mut Cx, args: Args) -> Result<Value> {
    let options = parse_component_options(cx, args, "runner/lemonade")?;
    let config = ProviderConfig::from_options(provider_profiles::lemonade(), cx, &options)?;
    provider_runner_value(cx, config)
}

fn provider_profiles_expr() -> Expr {
    Expr::Map(
        provider_profiles::all()
            .into_iter()
            .map(|profile| {
                (
                    Expr::Symbol(profile.provider.clone()),
                    provider_profile_expr(&profile),
                )
            })
            .collect(),
    )
}

fn provider_profile_expr(profile: &ProviderProfile) -> Expr {
    Expr::Map(vec![
        map_entry("provider", Expr::Symbol(profile.provider.clone())),
        map_entry("runner", Expr::Symbol(profile.runner_symbol.clone())),
        map_entry("codec", Expr::Symbol(profile.codec.clone())),
        map_entry("endpoint", Expr::String(profile.default_endpoint.clone())),
        map_entry("models-path", Expr::String(profile.models_path.to_owned())),
        map_entry("chat-path", Expr::String(profile.chat_path.to_owned())),
        map_entry("auth", provider_auth_expr(&profile.auth)),
        map_entry("locality", Expr::Symbol(profile.default_locality.clone())),
        map_entry("model", Expr::String(profile.default_model.clone())),
        map_entry(
            "timeout-ms",
            number_expr(profile.default_timeout.as_millis()),
        ),
        map_entry("stream", Expr::Bool(profile.default_stream)),
        map_entry("tools", Expr::Bool(profile.default_tools)),
        map_entry(
            "max-output-bytes",
            number_expr(profile.default_max_output_bytes),
        ),
    ])
}

fn provider_auth_expr(auth: &ProviderAuth) -> Expr {
    match auth {
        ProviderAuth::None => Expr::Map(vec![map_entry("kind", Expr::Symbol(Symbol::new("none")))]),
        ProviderAuth::BearerEnv { env } => Expr::Map(vec![
            map_entry("kind", Expr::Symbol(Symbol::new("bearer-env"))),
            map_entry("env", Expr::String(env.clone())),
        ]),
        ProviderAuth::OptionalBearerEnv { env } => Expr::Map(vec![
            map_entry("kind", Expr::Symbol(Symbol::new("optional-bearer-env"))),
            map_entry("env", Expr::String(env.clone())),
        ]),
        ProviderAuth::HeaderEnv { header, env } => Expr::Map(vec![
            map_entry("kind", Expr::Symbol(Symbol::new("header-env"))),
            map_entry("header", Expr::String(header.clone())),
            map_entry("env", Expr::String(env.clone())),
        ]),
    }
}

fn provider_profile_option(
    cx: &mut Cx,
    options: &HashMap<String, Value>,
) -> Result<ProviderProfile> {
    let Some(value) = options.get("provider").or_else(|| options.get("runner")) else {
        return Ok(provider_profiles::openai_compatible());
    };
    let name = match value.object().as_expr(cx)? {
        Expr::String(text) => text,
        Expr::Symbol(symbol) => symbol.to_string(),
        _ => {
            return Err(Error::Eval(
                "provider/probe :provider expects a string or symbol".to_owned(),
            ));
        }
    };
    provider_profile_named(&name)
        .ok_or_else(|| Error::Eval(format!("unknown provider profile {name}")))
}

fn provider_profile_named(name: &str) -> Option<ProviderProfile> {
    provider_profiles::all().into_iter().find(|profile| {
        name == profile.provider.to_string() || name == profile.runner_symbol.to_string()
    })
}

fn require_provider_probe_capabilities(cx: &Cx, config: &ProviderConfig) -> Result<()> {
    cx.require(&CapabilityName::new(AI_RUNNER_CAPABILITY))?;
    if config.locality == Symbol::new("local") {
        cx.require(&CapabilityName::new(AI_RUNNER_LOCAL_CAPABILITY))?;
    } else {
        cx.require(&CapabilityName::new(AI_RUNNER_NETWORK_CAPABILITY))?;
    }
    if config.api_key_env.is_some() {
        cx.require(&CapabilityName::new(AI_RUNNER_SECRET_CAPABILITY))?;
    }
    Ok(())
}

fn map_entry(name: &str, value: Expr) -> (Expr, Expr) {
    (Expr::Symbol(Symbol::new(name)), value)
}

fn number_expr(value: impl ToString) -> Expr {
    Expr::Number(NumberLiteral {
        domain: Symbol::qualified("numbers", "f64"),
        canonical: value.to_string(),
    })
}