sim-lib-openai-server 0.1.2

OpenAI-compatible gateway skeleton for SIM.
Documentation
use serde_json::Value;
use sim_kernel::ContentId;

use crate::{
    CHAT_COMPLETIONS_PATH, DeterministicGatewayClock, GatewayEvent, GatewayRequest, GatewayStore,
    MemoryGatewayStore, ResponseIdGenerators, configure_routes, execute_chat_completion_request,
    execute_response_request, gateway_event_data_from_packet, gateway_event_data_packets,
    routes::chat_completions::chat_completion_runtime_request,
};

#[test]
fn chat_completions_route_fixture_echo_returns_chat_completion() {
    let response = configure_routes().handle(&chat_request(
        r#"{"model":"fixture/echo","messages":[{"role":"system","content":"brief"},{"role":"user","content":"hello"}]}"#,
    ));
    let json = response_json(&response);

    assert_eq!(response.status(), 200);
    assert_eq!(response.header("Content-Type"), Some("application/json"));
    assert_eq!(json["object"], "chat.completion");
    assert!(json["id"].as_str().unwrap().starts_with("chatcmpl_"));
    assert_eq!(json["model"], "fixture/echo");
    assert_eq!(json["choices"][0]["message"]["role"], "assistant");
    assert_eq!(json["choices"][0]["message"]["content"], "hello brief");
    assert_eq!(json["choices"][0]["finish_reason"], "stop");
    assert_eq!(json["usage"]["total_tokens"], 4);
}

#[test]
fn chat_completions_route_streams_sse_chunks() {
    let response = configure_routes().handle(&chat_request(
        r#"{"model":"fixture/echo","messages":[{"role":"system","content":"brief"},{"role":"user","content":"hello"}],"stream":true}"#,
    ));
    let chunks = super::sse_json_chunks(&response);

    assert_eq!(response.status(), 200);
    assert_eq!(response.header("Content-Type"), Some("text/event-stream"));
    assert!(super::sse_ends_with_done(&response));
    assert_eq!(chunks[0]["object"], "chat.completion.chunk");
    assert_eq!(chunks[0]["choices"][0]["delta"]["role"], "assistant");
    assert_eq!(chat_stream_text(&chunks), "hello brief");
    let final_chunk = chunks
        .iter()
        .find(|chunk| chunk["choices"][0]["finish_reason"] == "stop")
        .unwrap();
    assert_eq!(final_chunk["choices"][0]["delta"], serde_json::json!({}));
}

#[test]
fn chat_completions_route_rejects_unsupported_roles() {
    let response = configure_routes().handle(&chat_request(
        r#"{"model":"fixture/echo","messages":[{"role":"tool","content":"x"},{"role":"user","content":"hello"}]}"#,
    ));
    let json = response_json(&response);

    assert_eq!(response.status(), 400);
    assert_eq!(json["error"]["param"], "messages");
    assert_eq!(json["error"]["code"], "unsupported_role");
    assert!(
        json["error"]["message"]
            .as_str()
            .unwrap()
            .contains("unsupported chat message role")
    );
}

#[test]
fn chat_completions_and_responses_share_runtime_event_log() {
    let chat = chat_request(
        r#"{"model":"fixture/echo","messages":[{"role":"system","content":"brief"},{"role":"user","content":"hello"}],"store":true}"#,
    );
    let runtime_request = chat_completion_runtime_request(&chat).unwrap();

    let mut chat_cx = super::cx();
    let mut chat_store = MemoryGatewayStore::new();
    let mut chat_ids = ResponseIdGenerators::deterministic(7);
    let mut chat_clock = DeterministicGatewayClock::new(1_000, 10);
    let chat_execution = execute_chat_completion_request(
        &mut chat_cx,
        &mut chat_store,
        &mut chat_ids,
        &mut chat_clock,
        &chat,
    );

    let mut response_cx = super::cx();
    let mut response_store = MemoryGatewayStore::new();
    let mut response_ids = ResponseIdGenerators::deterministic(7);
    let mut response_clock = DeterministicGatewayClock::new(1_000, 10);
    let response_execution = execute_response_request(
        &mut response_cx,
        &mut response_store,
        &mut response_ids,
        &mut response_clock,
        &runtime_request,
    );

    assert_eq!(chat_execution.response().status(), 200);
    assert_eq!(response_execution.response().status(), 200);
    assert_eq!(
        response_json(chat_execution.response())["object"],
        "chat.completion"
    );
    assert_eq!(
        response_json(response_execution.response())["object"],
        "response"
    );

    let chat_runtime = chat_execution.runtime().unwrap();
    assert_eq!(
        stored_events(&chat_store, chat_runtime.event_content_ids()),
        stored_events(&response_store, response_execution.event_content_ids())
    );
}

#[test]
fn streaming_chat_completion_data_packets_reconstruct_event_log() {
    let chat = chat_request(
        r#"{"model":"fixture/echo","messages":[{"role":"system","content":"brief"},{"role":"user","content":"hello"}],"store":true,"stream":true}"#,
    );
    let mut cx = super::cx();
    let mut store = MemoryGatewayStore::new();
    let mut ids = ResponseIdGenerators::deterministic(13);
    let mut clock = DeterministicGatewayClock::new(3_000, 30);

    let execution =
        execute_chat_completion_request(&mut cx, &mut store, &mut ids, &mut clock, &chat);
    let runtime = execution.runtime().unwrap();
    let packets = gateway_event_data_packets(runtime.events());
    let data = packets
        .iter()
        .map(gateway_event_data_from_packet)
        .collect::<sim_kernel::Result<Vec<_>>>()
        .unwrap();

    assert_eq!(
        execution.response().header("Content-Type"),
        Some("text/event-stream")
    );
    assert_eq!(
        chat_stream_text(&super::sse_json_chunks(execution.response())),
        "hello brief"
    );
    assert_eq!(data.len(), runtime.events().len());
    for (data, event) in data.iter().zip(runtime.events()) {
        assert_eq!(data.sequence(), event.sequence());
        assert_eq!(data.kind(), event.kind());
        assert_eq!(data.payload(), event.payload());
    }
}

fn chat_request(body: &str) -> GatewayRequest {
    GatewayRequest::new(
        "POST",
        CHAT_COMPLETIONS_PATH,
        vec![("Content-Type".to_owned(), "application/json".to_owned())],
        body.as_bytes().to_vec(),
    )
}

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

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

fn chat_stream_text(chunks: &[Value]) -> String {
    chunks
        .iter()
        .filter_map(|chunk| chunk["choices"].as_array())
        .filter_map(|choices| choices.first())
        .filter_map(|choice| choice["delta"]["content"].as_str())
        .collect()
}