sim-lib-openai-server 0.1.1

OpenAI-compatible gateway skeleton for SIM.
Documentation
use std::sync::{
    Arc,
    atomic::{AtomicUsize, Ordering},
};

use serde_json::{Value, json};
use sim_kernel::{Args, Callable, Cx, Error, Expr, Object, ObjectCompat, Result, Symbol};

use crate::{
    DeterministicGatewayClock, GatewayEvent, GatewayRequest, GatewayStore, MemoryGatewayStore,
    RESPONSES_PATH, ResponseIdGenerators, execute_response_request, install_openai_gateway_lib,
    openai_gateway_tools_capability,
};

#[test]
fn fixture_tool_call_invokes_explicit_test_tool_and_replays_events() {
    let calls = Arc::new(AtomicUsize::new(0));
    let mut cx = tool_cx();
    register_echo_tool(&mut cx, calls.clone());
    let mut store = MemoryGatewayStore::new();
    let mut ids = ResponseIdGenerators::deterministic(1);
    let mut clock = DeterministicGatewayClock::new(1_000, 10);
    let request = tool_request(
        "fixture/tool-call",
        "hello tool",
        echo_tool_descriptor(vec![]),
    );

    let execution = execute_response_request(&mut cx, &mut store, &mut ids, &mut clock, &request);
    let json = response_json(execution.response());

    assert_eq!(execution.response().status(), 200);
    assert!(
        json["output_text"]
            .as_str()
            .unwrap()
            .contains("echo: hello tool")
    );
    assert_eq!(calls.load(Ordering::SeqCst), 1);
    assert!(has_event(execution.events(), "tool-call"));
    assert!(has_event(execution.events(), "tool-result"));
    assert_eq!(
        stored_events(&store, execution.event_content_ids()),
        execution.events()
    );
}

#[test]
fn tool_loop_invokes_registered_gateway_callable() {
    let mut cx = tool_cx();
    install_openai_gateway_lib(&mut cx).unwrap();
    let mut store = MemoryGatewayStore::new();
    let mut ids = ResponseIdGenerators::deterministic(1);
    let mut clock = DeterministicGatewayClock::new(1_000, 10);
    let request = tool_request(
        "fixture/tool-call",
        r#"{"source":"fixture/echo"}"#,
        json!([{
            "type": "function",
            "function": {
                "name": "plan_parse",
                "description": "Parse a model plan.",
                "parameters": {
                    "type": "object",
                    "properties": { "source": { "type": "string" } },
                    "required": ["source"]
                },
                "x-sim-symbol": "openai-gateway/plan-parse"
            }
        }]),
    );

    let execution = execute_response_request(&mut cx, &mut store, &mut ids, &mut clock, &request);
    let json = response_json(execution.response());

    assert_eq!(execution.response().status(), 200);
    assert!(json["output_text"].as_str().unwrap().contains("plan/atom"));
    assert!(has_event(execution.events(), "tool-result"));
}

#[test]
fn capability_denied_tool_is_recorded_without_running() {
    let calls = Arc::new(AtomicUsize::new(0));
    let mut cx = tool_cx();
    register_echo_tool(&mut cx, calls.clone());
    let mut store = MemoryGatewayStore::new();
    let mut ids = ResponseIdGenerators::deterministic(1);
    let mut clock = DeterministicGatewayClock::new(1_000, 10);
    let request = tool_request(
        "fixture/tool-call",
        "secret",
        echo_tool_descriptor(vec!["tool-secret"]),
    );

    let execution = execute_response_request(&mut cx, &mut store, &mut ids, &mut clock, &request);
    let json = response_json(execution.response());
    let result = event_payload(execution.events(), "tool-result");

    assert_eq!(execution.response().status(), 200);
    assert_eq!(calls.load(Ordering::SeqCst), 0);
    assert!(format!("{result:?}").contains("capability-denied"));
    assert!(
        json["output_text"]
            .as_str()
            .unwrap()
            .contains("capability-denied")
    );
}

#[test]
fn invalid_tool_arguments_are_structured_tool_results() {
    let calls = Arc::new(AtomicUsize::new(0));
    let mut cx = tool_cx();
    register_echo_tool(&mut cx, calls.clone());
    let mut store = MemoryGatewayStore::new();
    let mut ids = ResponseIdGenerators::deterministic(1);
    let mut clock = DeterministicGatewayClock::new(1_000, 10);
    let request = tool_request("fixture/tool-call", r#"{}"#, echo_tool_descriptor(vec![]));

    let execution = execute_response_request(&mut cx, &mut store, &mut ids, &mut clock, &request);
    let json = response_json(execution.response());
    let result = event_payload(execution.events(), "tool-result");

    assert_eq!(execution.response().status(), 200);
    assert_eq!(calls.load(Ordering::SeqCst), 0);
    assert!(format!("{result:?}").contains("invalid-arguments"));
    assert!(
        json["output_text"]
            .as_str()
            .unwrap()
            .contains("invalid-arguments")
    );
}

#[test]
fn repeated_identical_tool_call_fails_closed() {
    let calls = Arc::new(AtomicUsize::new(0));
    let mut cx = tool_cx();
    register_echo_tool(&mut cx, calls);
    let mut store = MemoryGatewayStore::new();
    let mut ids = ResponseIdGenerators::deterministic(1);
    let mut clock = DeterministicGatewayClock::new(1_000, 10);
    let request = tool_request(
        "fixture/repeat-tool-call",
        "repeat",
        echo_tool_descriptor(vec![]),
    );

    let execution = execute_response_request(&mut cx, &mut store, &mut ids, &mut clock, &request);
    let json = response_json(execution.response());

    assert_eq!(execution.response().status(), 400);
    assert!(
        json["error"]["message"]
            .as_str()
            .unwrap()
            .contains("repeated")
    );
    assert_eq!(json["error"]["code"], "invalid_model");
}

#[test]
fn phase0_openai_tool_loop_still_calls_registered_function_by_symbol() {
    let mut cx = tool_cx();
    let calls = Arc::new(AtomicUsize::new(0));
    register_echo_tool(&mut cx, calls.clone());
    let request = tool_request("fixture/tool-call", "phase 0", echo_tool_descriptor(vec![]));
    let mut store = MemoryGatewayStore::new();
    let mut ids = ResponseIdGenerators::deterministic(1);
    let mut clock = DeterministicGatewayClock::new(1_000, 10);

    let execution = execute_response_request(&mut cx, &mut store, &mut ids, &mut clock, &request);

    assert_eq!(execution.response().status(), 200);
    assert_eq!(calls.load(Ordering::SeqCst), 1);
    assert!(has_event(execution.events(), "tool-call"));
    assert!(has_event(execution.events(), "tool-result"));
    let source = include_str!("../runtime/tool_loop.rs");
    assert!(source.contains("cx.call_function(tool.symbol(), Args::new(args))"));
}

fn tool_cx() -> Cx {
    let mut cx = super::cx();
    cx.grant(openai_gateway_tools_capability());
    cx
}

fn register_echo_tool(cx: &mut Cx, calls: Arc<AtomicUsize>) {
    let value = cx.factory().opaque(Arc::new(EchoTool { calls })).unwrap();
    cx.registry_mut()
        .register_function_value(Symbol::qualified("tool", "echo"), value)
        .unwrap();
}

fn tool_request(model: &str, input: &str, tools: Value) -> GatewayRequest {
    GatewayRequest::new(
        "POST",
        RESPONSES_PATH,
        vec![("Content-Type".to_owned(), "application/json".to_owned())],
        serde_json::to_vec(&json!({
            "model": model,
            "input": input,
            "store": true,
            "tools": tools
        }))
        .unwrap(),
    )
}

fn echo_tool_descriptor(capabilities: Vec<&str>) -> Value {
    json!([{
        "type": "function",
        "function": {
            "name": "tool_echo",
            "description": "Echo text.",
            "parameters": {
                "type": "object",
                "properties": { "text": { "type": "string" } },
                "required": ["text"]
            },
            "x-sim-symbol": "tool/echo",
            "x-sim-capabilities": capabilities
        }
    }])
}

fn response_json(response: &crate::GatewayResponse) -> Value {
    serde_json::from_slice(response.body()).unwrap()
}

fn has_event(events: &[GatewayEvent], kind: &str) -> bool {
    events
        .iter()
        .any(|event| event.kind().name.as_ref() == kind)
}

fn event_payload<'a>(events: &'a [GatewayEvent], kind: &str) -> &'a Expr {
    events
        .iter()
        .find(|event| event.kind().name.as_ref() == kind)
        .map(GatewayEvent::payload)
        .unwrap_or_else(|| panic!("missing {kind} event"))
}

fn stored_events(store: &MemoryGatewayStore, ids: &[sim_kernel::ContentId]) -> Vec<GatewayEvent> {
    ids.iter().map(|id| store.event(id).unwrap()).collect()
}

struct EchoTool {
    calls: Arc<AtomicUsize>,
}

impl Object for EchoTool {
    fn display(&self, _cx: &mut Cx) -> Result<String> {
        Ok("#<openai-test-echo-tool>".to_owned())
    }

    fn as_any(&self) -> &dyn std::any::Any {
        self
    }
}

impl ObjectCompat for EchoTool {
    fn as_callable(&self) -> Option<&dyn Callable> {
        Some(self)
    }
}

impl Callable for EchoTool {
    fn call(&self, cx: &mut Cx, args: Args) -> Result<sim_kernel::Value> {
        self.calls.fetch_add(1, Ordering::SeqCst);
        let mut args = expect_arg_count(args, 1)?.into_iter();
        let text = value_string(cx, args.next().expect("arg count checked"))?;
        cx.factory().string(format!("echo: {text}"))
    }
}

fn expect_arg_count(args: Args, expected: usize) -> Result<Vec<sim_kernel::Value>> {
    let args = args.into_vec();
    if args.len() == expected {
        Ok(args)
    } else {
        Err(Error::Eval(format!(
            "echo tool expects {expected} argument(s), found {}",
            args.len()
        )))
    }
}

fn value_string(cx: &mut Cx, value: sim_kernel::Value) -> Result<String> {
    match value.object().as_expr(cx)? {
        Expr::String(text) => Ok(text),
        _ => Err(Error::TypeMismatch {
            expected: "string",
            found: "non-string",
        }),
    }
}