nexil 0.9.0

Provider-agnostic LLM toolkit — streaming, tool calls, tape storage, OAuth
use serde_json::{Map, Value};

use crate::adapter::ProviderAdapter;
use crate::clients::parsing::TransportKind;
use crate::core::errors::{ConduitError, ErrorKind};
use crate::core::execution::LLMCore;
use crate::core::request_builder::TransportCallRequest;

pub static OPENAI_ADAPTER: OpenAIAdapter = OpenAIAdapter;

pub struct OpenAIAdapter;

impl ProviderAdapter for OpenAIAdapter {
    fn build_request_url(&self, api_base: &str, transport: TransportKind) -> String {
        let base = api_base.trim_end_matches('/');
        match transport {
            TransportKind::Completion => format!("{}/chat/completions", base),
            TransportKind::Responses => format!("{}/responses", base),
            TransportKind::Messages => format!("{}/messages", base),
        }
    }

    fn build_request_body(
        &self,
        request: &TransportCallRequest,
        transport: TransportKind,
    ) -> Result<Value, ConduitError> {
        match transport {
            TransportKind::Completion => Ok(self.build_completion_body(request)),
            TransportKind::Responses => Ok(self.build_responses_body(request)),
            TransportKind::Messages => Err(ConduitError::new(
                ErrorKind::Config,
                "openai adapter does not support messages transport",
            )),
        }
    }
}

impl OpenAIAdapter {
    fn build_completion_body(&self, request: &TransportCallRequest) -> Value {
        let mut body = base_completion_body(request);
        insert_completion_options(&mut body, request);
        insert_openai_kwargs(&mut body, completion_kwargs(request));
        Value::Object(body)
    }

    fn build_responses_body(&self, request: &TransportCallRequest) -> Value {
        let mut body = base_responses_body(request);
        insert_responses_options(&mut body, request);
        insert_openai_kwargs(&mut body, responses_kwargs(request));
        apply_chatgpt_backend_options(&mut body, request);
        Value::Object(body)
    }
}

fn base_completion_body(request: &TransportCallRequest) -> Map<String, Value> {
    let mut body = Map::new();
    body.insert("model".to_owned(), Value::String(request.model_id.clone()));
    body.insert(
        "messages".to_owned(),
        Value::Array(request.messages_payload.clone()),
    );
    body
}

fn insert_completion_options(body: &mut Map<String, Value>, request: &TransportCallRequest) {
    if request.stream {
        body.insert("stream".to_owned(), Value::Bool(true));
    }
    if let Some(ref tools) = request.tools_payload
        && !tools.is_empty()
    {
        body.insert("tools".to_owned(), Value::Array(tools.clone()));
    }
    insert_reasoning_effort(body, &request.reasoning_effort);
}

fn insert_reasoning_effort(body: &mut Map<String, Value>, effort: &Option<Value>) {
    if let Some(effort) = effort
        && !effort.is_null()
    {
        body.insert("reasoning_effort".to_owned(), effort.clone());
    }
}

fn completion_kwargs(request: &TransportCallRequest) -> Map<String, Value> {
    let kwargs = LLMCore::decide_kwargs_for_provider(
        &request.provider_name,
        request.max_tokens,
        &request.kwargs,
    );
    LLMCore::with_default_completion_stream_options(&request.provider_name, request.stream, &kwargs)
}

fn base_responses_body(request: &TransportCallRequest) -> Map<String, Value> {
    let (instructions, input_items) =
        LLMCore::split_messages_for_responses(&request.messages_payload);
    let mut body = Map::new();
    body.insert("model".to_owned(), Value::String(request.model_id.clone()));
    body.insert("input".to_owned(), Value::Array(input_items));
    body.insert(
        "instructions".to_owned(),
        Value::String(instructions.unwrap_or_default()),
    );
    body
}

fn insert_responses_options(body: &mut Map<String, Value>, request: &TransportCallRequest) {
    if request.stream {
        body.insert("stream".to_owned(), Value::Bool(true));
    }
    if let Some(ref tools) = request.tools_payload
        && let Some(converted) = LLMCore::convert_tools_for_responses(Some(tools))
    {
        body.insert("tools".to_owned(), Value::Array(converted));
    }
}

fn responses_kwargs(request: &TransportCallRequest) -> Map<String, Value> {
    let effective_reasoning = request
        .reasoning_effort
        .clone()
        .or_else(|| default_gpt5_reasoning(&request.model_id));
    let kwargs = LLMCore::with_responses_reasoning(&request.kwargs, effective_reasoning.as_ref());
    LLMCore::decide_responses_kwargs(request.max_tokens, &kwargs, true)
}

fn default_gpt5_reasoning(model_id: &str) -> Option<Value> {
    model_id
        .starts_with("gpt-5")
        .then(|| Value::String("low".to_owned()))
}

fn insert_openai_kwargs(body: &mut Map<String, Value>, kwargs: Map<String, Value>) {
    for (key, value) in kwargs {
        if key != "session_id" {
            body.entry(key).or_insert(value);
        }
    }
}

fn apply_chatgpt_backend_options(body: &mut Map<String, Value>, request: &TransportCallRequest) {
    if request
        .api_base
        .as_deref()
        .is_some_and(|b| b.contains("chatgpt.com"))
    {
        body.insert("store".to_owned(), Value::Bool(false));
        body.insert("stream".to_owned(), Value::Bool(true));
        body.remove("max_output_tokens");
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::Arc;

    fn make_request(session_id: Option<String>) -> TransportCallRequest {
        TransportCallRequest {
            client: Arc::new(reqwest::Client::new()),
            provider_name: "openai".to_owned(),
            model_id: "gpt-4o".to_owned(),
            api_base: Some("https://api.openai.com/v1".to_owned()),
            messages_payload: vec![serde_json::json!({"role": "user", "content": "hi"})],
            tools_payload: None,
            max_tokens: Some(64),
            stream: false,
            reasoning_effort: None,
            kwargs: serde_json::Map::new(),
            is_anthropic_oauth: false,
            session_id,
            prompt_cache: false,
        }
    }

    #[test]
    fn test_openai_completion_body_omits_session_id_when_set() {
        let req = make_request(Some("sess-42".to_owned()));
        let body = OPENAI_ADAPTER.build_completion_body(&req);
        assert!(body.get("session_id").is_none());
    }

    #[test]
    fn test_openai_completion_body_omits_session_id_when_none() {
        let req = make_request(None);
        let body = OPENAI_ADAPTER.build_completion_body(&req);
        assert!(body.get("session_id").is_none());
    }

    #[test]
    fn test_openai_responses_body_omits_session_id_when_set() {
        let req = make_request(Some("sess-42".to_owned()));
        let body = OPENAI_ADAPTER.build_responses_body(&req);
        assert!(body.get("session_id").is_none());
    }

    #[test]
    fn test_openai_kwargs_session_id_is_filtered() {
        let mut req = make_request(Some("sess-42".to_owned()));
        req.kwargs.insert(
            "session_id".to_owned(),
            Value::String("from-kwargs".to_owned()),
        );
        let body = OPENAI_ADAPTER.build_completion_body(&req);
        assert!(body.get("session_id").is_none());
    }
}