looprs 0.5.0

Concise coding assistant REPL — core library
Documentation
use async_openai::Client;
use async_openai::config::OpenAIConfig;
use serde_json::{Value, json};

use crate::api::ContentBlock;
use crate::errors::ProviderError;
use crate::types::ModelId;

use super::{InferenceRequest, InferenceResponse, LLMProvider, Usage};

pub struct OpenAISdkProvider {
    client: Client<OpenAIConfig>,
    key: String,
    model: ModelId,
}

impl OpenAISdkProvider {
    pub fn new(key: String) -> Result<Self, ProviderError> {
        let model = std::env::var("MODEL").ok().map(ModelId::new);
        Self::new_with_model(key, model)
    }

    pub fn new_with_model(key: String, model: Option<ModelId>) -> Result<Self, ProviderError> {
        let model = model.unwrap_or_else(ModelId::gpt_5_mini);
        let config = OpenAIConfig::new().with_api_key(&key);
        let client = Client::with_config(config);
        Ok(Self { client, key, model })
    }

    fn convert_to_openai_messages(msg: &crate::api::Message) -> Vec<Value> {
        super::convert_to_openai_messages(msg)
    }

    fn parse_tool_arguments(args: &Value) -> Result<Value, ProviderError> {
        match args {
            Value::String(raw) => serde_json::from_str(raw).map_err(|e| {
                ProviderError::InvalidResponse(format!("Invalid tool call arguments JSON: {e}"))
            }),
            other => Ok(other.clone()),
        }
    }

    fn parse_tool_call(tool_call: &Value) -> Result<ContentBlock, ProviderError> {
        let id = tool_call
            .get("id")
            .and_then(Value::as_str)
            .filter(|id| !id.is_empty())
            .ok_or_else(|| {
                ProviderError::InvalidResponse("Tool call id missing or invalid".to_string())
            })?;

        let function = tool_call.get("function").ok_or_else(|| {
            ProviderError::InvalidResponse("Tool call function missing".to_string())
        })?;

        let name = function
            .get("name")
            .and_then(Value::as_str)
            .filter(|name| !name.is_empty())
            .ok_or_else(|| {
                ProviderError::InvalidResponse(
                    "Tool call function name missing or invalid".to_string(),
                )
            })?;

        let args = function.get("arguments").ok_or_else(|| {
            ProviderError::InvalidResponse("Tool call arguments missing".to_string())
        })?;

        let input = Self::parse_tool_arguments(args)?;

        Ok(ContentBlock::ToolUse {
            id: crate::types::ToolId::new(id),
            name: crate::types::ToolName::new(name),
            input,
        })
    }
}

#[async_trait::async_trait]
impl LLMProvider for OpenAISdkProvider {
    // qual:allow(iosp) reason: "I/O boundary — builds and sends HTTP request to OpenAI API"
    async fn infer(
        &self,
        req: &InferenceRequest,
    ) -> Result<InferenceResponse, Box<dyn std::error::Error + Send + Sync>> {
        let tools = req
            .tools
            .iter()
            .map(|t| {
                json!({
                    "type": "function",
                    "function": {
                        "name": t.name,
                        "description": t.description,
                        "parameters": t.input_schema,
                    }
                })
            })
            .collect::<Vec<_>>();

        let model = req.model.as_str();
        let is_reasoning = super::is_reasoning_model(model);
        let uses_completion_tokens = model.starts_with("gpt-5")
            || model.starts_with("gpt-4o")
            || model.starts_with("gpt-4-turbo-2024")
            || is_reasoning;

        let mut body = json!({
            "model": req.model.as_str(),
            "messages": vec![
                json!({
                    "role": "system",
                    "content": req.system
                })
            ]
            .into_iter()
            .chain(req.messages.iter().flat_map(Self::convert_to_openai_messages))
            .collect::<Vec<_>>(),
            "tools": tools,
            "tool_choice": if tools.is_empty() { "none" } else { "auto" }
        });

        if uses_completion_tokens {
            body["max_completion_tokens"] = json!(req.max_tokens);
        } else {
            body["max_tokens"] = json!(req.max_tokens);
        }

        if super::supports_temperature(model)
            && let Some(temp) = req.temperature
        {
            body["temperature"] = json!(temp);
        }

        let response_json: Value = self
            .client
            .chat()
            .create_byot(body)
            .await
            .map_err(|e| ProviderError::ApiError(format!("OpenAI SDK Error: {e}")))?;

        let choice = response_json
            .get("choices")
            .and_then(|arr| arr.as_array())
            .and_then(|arr| arr.first())
            .ok_or_else(|| ProviderError::InvalidResponse("No choices in response".to_string()))?;

        let message = choice
            .get("message")
            .ok_or_else(|| ProviderError::InvalidResponse("No message in choice".to_string()))?;

        let mut blocks = Vec::new();

        if let Some(text) = message.get("content").and_then(|v| v.as_str())
            && !text.is_empty()
        {
            blocks.push(ContentBlock::Text {
                text: text.to_string(),
            });
        }

        if let Some(tool_calls) = message.get("tool_calls").and_then(|v| v.as_array()) {
            for tool_call in tool_calls {
                blocks.push(Self::parse_tool_call(tool_call)?);
            }
        }

        let stop_reason = choice
            .get("finish_reason")
            .and_then(|v| v.as_str())
            .unwrap_or("stop")
            .to_string();

        let usage = if let Some(usage_obj) = response_json.get("usage") {
            Usage {
                input_tokens: usage_obj
                    .get("prompt_tokens")
                    .and_then(|v| v.as_u64())
                    .unwrap_or(0) as u32,
                output_tokens: usage_obj
                    .get("completion_tokens")
                    .and_then(|v| v.as_u64())
                    .unwrap_or(0) as u32,
            }
        } else {
            Usage {
                input_tokens: 0,
                output_tokens: 0,
            }
        };

        Ok(InferenceResponse {
            content: blocks,
            stop_reason,
            usage,
        })
    }

    fn name(&self) -> &str {
        "openai-sdk"
    }

    fn model(&self) -> &ModelId {
        &self.model
    }

    fn validate_config(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
        if self.key.is_empty() {
            return Err(ProviderError::Config("OpenAI API key is empty".to_string()).into());
        }
        Ok(())
    }
}

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

    #[test]
    fn parse_tool_call_rejects_missing_id() {
        let tool_call = json!({
            "function": {
                "name": "read",
                "arguments": "{\"path\":\"README.md\"}"
            }
        });

        let err = OpenAISdkProvider::parse_tool_call(&tool_call).unwrap_err();
        assert!(matches!(err, ProviderError::InvalidResponse(_)));
        assert!(err.to_string().contains("Tool call id missing or invalid"));
    }

    #[test]
    fn parse_tool_call_rejects_invalid_json_arguments() {
        let tool_call = json!({
            "id": "call_1",
            "function": {
                "name": "read",
                "arguments": "{not json}"
            }
        });

        let err = OpenAISdkProvider::parse_tool_call(&tool_call).unwrap_err();
        assert!(matches!(err, ProviderError::InvalidResponse(_)));
        assert!(err.to_string().contains("Invalid tool call arguments JSON"));
    }

    #[test]
    fn parse_tool_call_accepts_valid_payload() {
        let tool_call = json!({
            "id": "call_2",
            "function": {
                "name": "read",
                "arguments": "{\"path\":\"README.md\"}"
            }
        });

        let parsed = OpenAISdkProvider::parse_tool_call(&tool_call).unwrap();

        match parsed {
            ContentBlock::ToolUse { id, name, input } => {
                assert_eq!(id.as_str(), "call_2");
                assert_eq!(name.as_str(), "read");
                assert_eq!(input.get("path").and_then(Value::as_str), Some("README.md"));
            }
            _ => panic!("expected tool use block"),
        }
    }

    #[test]
    fn openai_sdk_provider_satisfies_inference_provider_contract() {
        use looprs_core::ports::test_contracts::assert_inference_provider_contract;
        let p = OpenAISdkProvider::new("test-key".to_string())
            .expect("OpenAISdkProvider::new must succeed in test");
        assert_inference_provider_contract(&p);
    }
}