sim-lib-openai-server 0.1.3

OpenAI-compatible gateway skeleton for SIM.
Documentation
use sim_codec_chat::text_part;
use sim_kernel::{Expr, Symbol};
use sim_lib_agent_runner_core::{ModelRequest, ModelResponse, ModelUsage};

pub(crate) fn fixture_echo_response(model: &str, request: &ModelRequest) -> ModelResponse {
    fixture_static_response(model, &request_text(request))
}

pub(crate) fn fixture_tool_call_response(
    model: &str,
    request: &ModelRequest,
    repeat: bool,
) -> ModelResponse {
    let tool_results = tool_result_texts(request);
    if !repeat && !tool_results.is_empty() {
        return fixture_static_response(model, &tool_results.join("\n"));
    }
    ModelResponse::new(
        Symbol::new(model.to_owned()),
        model,
        vec![tool_call_part(
            selected_tool_name(request),
            fixture_arguments_json(request),
        )],
        Symbol::new("tool-call"),
    )
}

pub(crate) fn fixture_static_response(model: &str, text: &str) -> ModelResponse {
    let mut response = ModelResponse::new(
        Symbol::new(model.to_owned()),
        model,
        vec![text_part(text)],
        Symbol::new("stop"),
    );
    let tokens = text.split_whitespace().count() as u64;
    response.usage = Some(ModelUsage {
        input_tokens: Some(tokens),
        output_tokens: Some(tokens),
        latency_ms: Some(if model == "fixture/slow-echo" {
            1_000
        } else {
            0
        }),
        cost_usd: Some(0.0),
        extra: Vec::new(),
    });
    response
}

pub(crate) fn request_text(request: &ModelRequest) -> String {
    let mut parts = Vec::new();
    collect_text(&request.task, &mut parts);
    for message in &request.messages {
        collect_text(message, &mut parts);
    }
    if parts.is_empty() {
        "fixture echo".to_owned()
    } else {
        parts.join(" ")
    }
}

pub(crate) fn response_text(response: &ModelResponse) -> String {
    let mut parts = Vec::new();
    for item in &response.content {
        collect_text(item, &mut parts);
    }
    parts.join(" ")
}

pub(crate) fn response_summary(response: &ModelResponse) -> Expr {
    Expr::Map(vec![
        field("runner", Expr::Symbol(response.runner.clone())),
        field("model", Expr::String(response.model.clone())),
        field("text", Expr::String(response_text(response))),
    ])
}

fn collect_text(expr: &Expr, parts: &mut Vec<String>) {
    match expr {
        Expr::String(text) => parts.push(text.clone()),
        Expr::List(items) | Expr::Vector(items) | Expr::Set(items) | Expr::Block(items) => {
            for item in items {
                collect_text(item, parts);
            }
        }
        Expr::Map(entries) => {
            for (_, value) in entries {
                collect_text(value, parts);
            }
        }
        Expr::Call { operator, args } => {
            collect_text(operator, parts);
            for arg in args {
                collect_text(arg, parts);
            }
        }
        Expr::Infix { left, right, .. } => {
            collect_text(left, parts);
            collect_text(right, parts);
        }
        Expr::Prefix { arg, .. } | Expr::Postfix { arg, .. } | Expr::Quote { expr: arg, .. } => {
            collect_text(arg, parts);
        }
        Expr::Annotated { expr, annotations } => {
            collect_text(expr, parts);
            for (_, value) in annotations {
                collect_text(value, parts);
            }
        }
        Expr::Extension { payload, .. } => collect_text(payload, parts),
        Expr::Nil
        | Expr::Bool(_)
        | Expr::Number(_)
        | Expr::Symbol(_)
        | Expr::Local(_)
        | Expr::Bytes(_) => {}
    }
}

use sim_value::build::entry as field;

fn tool_call_part(name: String, arguments_json: String) -> Expr {
    Expr::Map(vec![
        field("type", Expr::Symbol(Symbol::new("tool-call"))),
        field("id", Expr::String(format!("call_{name}_000001"))),
        field("name", Expr::String(name)),
        field("arguments", Expr::String(arguments_json)),
    ])
}

fn selected_tool_name(request: &ModelRequest) -> String {
    request
        .extra
        .iter()
        .find_map(|(key, value)| {
            matches!(key, Expr::Symbol(symbol) if symbol.name.as_ref() == "tool-choice")
                .then_some(value)
        })
        .and_then(tool_choice_name)
        .or_else(|| {
            request
                .extra
                .iter()
                .find_map(|(key, value)| {
                    matches!(key, Expr::Symbol(symbol) if symbol.name.as_ref() == "tools")
                        .then_some(value)
                })
                .and_then(first_tool_name)
        })
        .unwrap_or_else(|| "tool_echo".to_owned())
}

fn tool_choice_name(expr: &Expr) -> Option<String> {
    match expr {
        Expr::String(name) if name != "auto" && name != "none" && name != "required" => {
            Some(name.clone())
        }
        Expr::Map(entries) => entries.iter().find_map(|(key, value)| {
            if !matches!(key, Expr::Symbol(symbol) if symbol.name.as_ref() == "function") {
                return None;
            }
            let Expr::Map(function) = value else {
                return None;
            };
            string_field(function, "name")
        }),
        _ => None,
    }
}

fn first_tool_name(expr: &Expr) -> Option<String> {
    let Expr::List(tools) = expr else {
        return None;
    };
    tools.iter().find_map(|tool| {
        let Expr::Map(entries) = tool else {
            return None;
        };
        entries.iter().find_map(|(key, value)| {
            if !matches!(key, Expr::Symbol(symbol) if symbol.name.as_ref() == "function") {
                return None;
            }
            let Expr::Map(function) = value else {
                return None;
            };
            string_field(function, "name")
        })
    })
}

fn fixture_arguments_json(request: &ModelRequest) -> String {
    let text = match &request.task {
        Expr::String(text) => text.clone(),
        _ => request_text(request),
    };
    match serde_json::from_str::<serde_json::Value>(&text) {
        Ok(serde_json::Value::Object(_)) => text,
        _ => serde_json::json!({ "text": text }).to_string(),
    }
}

fn tool_result_texts(request: &ModelRequest) -> Vec<String> {
    request
        .messages
        .iter()
        .filter(|message| message_role(message).as_deref() == Some("tool"))
        .map(|message| {
            let mut parts = Vec::new();
            collect_text(message, &mut parts);
            parts.join(" ")
        })
        .collect()
}

fn message_role(expr: &Expr) -> Option<String> {
    let Expr::Map(entries) = expr else {
        return None;
    };
    entries.iter().find_map(|(key, value)| match (key, value) {
        (Expr::Symbol(key), Expr::Symbol(value))
            if key.namespace.is_none() && key.name.as_ref() == "role" =>
        {
            Some(value.name.as_ref().to_owned())
        }
        _ => None,
    })
}

fn string_field(entries: &[(Expr, Expr)], name: &str) -> Option<String> {
    sim_value::access::entry_field(entries, name)
        .and_then(sim_value::access::as_str)
        .map(str::to_owned)
}