openai-api-dispatch 0.1.1

OpenAI-compatible chat requests through memory or NATS queues.
Documentation
use std::time::Duration;

use serde_json::Value;
use tokio::time::timeout;

use super::ExecutorAsyncOpenai;
use crate::{
    executor::Executor as _,
    task::{Interaction, TaskBuilder},
};

const TEST_TIMEOUT: Duration = Duration::from_secs(60);

fn default_executor() -> (ExecutorAsyncOpenai, String) {
    let executor = ExecutorAsyncOpenai::from_env_or_default();
    let model = executor
        .default_model()
        .as_ref()
        .clone()
        .expect("set OPENAI_API_DEFAULT_MODEL when running OpenAI executor tests");

    (executor, model)
}

#[tokio::test]
#[ignore = "requires an OpenAI-compatible server and OPENAI_API_DEFAULT_MODEL"]
async fn default_executor_completes_a_chat_task_with_a_system_prompt() {
    let (executor, model) = default_executor();
    let task = TaskBuilder::new()
        .with_system("Reply briefly.")
        .with_prompt("Say hello.")
        .with_max_tokens(64)
        .build_chat()
        .unwrap();

    let response = timeout(TEST_TIMEOUT, executor.execute(task))
        .await
        .expect("timed out waiting for the OpenAI response")
        .unwrap();

    assert!(
        response.success,
        "executor returned an unsuccessful response: {response:?}"
    );
    assert_eq!(response.model, model);
    assert!(!response.contents.trim().is_empty());

    let chat = response.task.try_to_chat().unwrap();
    assert_eq!(chat.system.as_deref(), Some("Reply briefly."));
    assert_eq!(
        chat.history.unwrap(),
        [
            Interaction::user("Say hello."),
            Interaction::assistant(response.contents),
        ]
    );
}

#[tokio::test]
#[ignore = "requires an OpenAI-compatible server and OPENAI_API_DEFAULT_MODEL"]
async fn default_executor_returns_schema_constrained_output() {
    let (executor, model) = default_executor();
    let schema = serde_json::json!({
        "type": "object",
        "properties": {
            "answer": {
                "type": "string"
            }
        },
        "required": ["answer"],
        "additionalProperties": false
    });
    let task = TaskBuilder::new()
        .with_schema(schema.clone())
        .with_prompt("Return a short greeting in the answer field.")
        .with_max_tokens(64)
        .build_chat()
        .unwrap();

    let response = timeout(TEST_TIMEOUT, executor.execute(task))
        .await
        .expect("timed out waiting for the OpenAI response")
        .unwrap();

    assert!(
        response.success,
        "executor returned an unsuccessful response: {response:?}"
    );
    assert_eq!(response.model, model);

    let output: Value = serde_json::from_str(&response.contents)
        .expect("schema-constrained response should contain valid JSON");
    let output = output
        .as_object()
        .expect("schema-constrained response should be an object");
    assert_eq!(output.len(), 1);
    assert!(output.get("answer").is_some_and(Value::is_string));

    let chat = response.task.try_to_chat().unwrap();
    assert_eq!(chat.schema, Some(schema));
}

#[tokio::test]
#[ignore = "requires an OpenAI-compatible server and OPENAI_API_DEFAULT_MODEL"]
async fn default_executor_preserves_user_and_assistant_history() {
    let (executor, model) = default_executor();
    let task = TaskBuilder::new()
        .with_history([
            Interaction::System("old system message".into()),
            Interaction::user("My name is Ada."),
            Interaction::assistant("Hello, Ada."),
        ])
        .with_prompt("What is my name?")
        .with_max_tokens(64)
        .build_chat()
        .unwrap();

    let response = timeout(TEST_TIMEOUT, executor.execute(task))
        .await
        .expect("timed out waiting for the OpenAI response")
        .unwrap();

    assert!(
        response.success,
        "executor returned an unsuccessful response: {response:?}"
    );
    assert_eq!(response.model, model);

    let chat = response.task.try_to_chat().unwrap();
    assert_eq!(
        chat.history.unwrap(),
        [
            Interaction::user("My name is Ada."),
            Interaction::assistant("Hello, Ada."),
            Interaction::user("What is my name?"),
            Interaction::assistant(response.contents),
        ]
    );
}