greentic-flow-builder 0.3.1

Greentic Flow Builder — orchestrator that powers Adaptive Card design via the adaptive-card-mcp toolkit
Documentation
//! OpenAI Chat Completions client with tool-calling support.
//!
//! `chat_with_tools` is the new entry used by the multi-turn tool loop in
//! `routes/chat.rs`. `chat_json` is kept as a legacy passthrough for callers
//! that just want a single JSON response with no tools.

use serde::{Deserialize, Serialize};
use serde_json::Value;

#[derive(Serialize)]
pub struct ChatRequest<'a> {
    pub model: &'a str,
    pub messages: Vec<Value>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub tools: Option<&'a [ToolDef]>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub tool_choice: Option<&'a str>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub response_format: Option<Value>,
}

#[derive(Serialize, Clone)]
pub struct ToolDef {
    #[serde(rename = "type")]
    pub kind: &'static str, // always "function"
    pub function: ToolFn,
}

#[derive(Serialize, Clone)]
pub struct ToolFn {
    pub name: &'static str,
    pub description: &'static str,
    pub parameters: Value, // JSON Schema
}

#[derive(Deserialize, Clone, Serialize)]
pub struct ChatResponse {
    pub choices: Vec<Choice>,
}

#[derive(Deserialize, Clone, Serialize)]
pub struct Choice {
    pub message: AssistantMessage,
    #[serde(default)]
    pub finish_reason: String,
}

#[derive(Deserialize, Clone, Serialize)]
pub struct AssistantMessage {
    /// Role field — always "assistant" on OpenAI responses. We must preserve
    /// and re-emit this when pushing the message back into the history for
    /// the next tool-calling round, otherwise OpenAI rejects the next
    /// request with `400 Missing required parameter: 'messages[N].role'`.
    #[serde(default = "default_assistant_role")]
    pub role: String,
    /// Content is `null` when the assistant chose to call tools instead of
    /// replying with text. `skip_serializing_if` drops the field entirely
    /// in that case so OpenAI sees an absent content (valid) rather than
    /// `content: null` (may be rejected in some API versions).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub content: Option<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub tool_calls: Vec<ToolCall>,
}

fn default_assistant_role() -> String {
    "assistant".to_string()
}

#[derive(Deserialize, Clone, Serialize)]
pub struct ToolCall {
    pub id: String,
    #[serde(rename = "type")]
    pub kind: String,
    pub function: ToolCallFn,
}

#[derive(Deserialize, Clone, Serialize)]
pub struct ToolCallFn {
    pub name: String,
    /// JSON-encoded string per OpenAI's wire format. Caller parses with serde_json.
    pub arguments: String,
}

/// Tool-aware chat call. Returns the full response so the caller can inspect
/// finish_reason and tool_calls.
pub async fn chat_with_tools(
    api_key: &str,
    request: ChatRequest<'_>,
) -> anyhow::Result<ChatResponse> {
    let client = reqwest::Client::new();
    let response = client
        .post("https://api.openai.com/v1/chat/completions")
        .header("Authorization", format!("Bearer {api_key}"))
        .header("Content-Type", "application/json")
        .json(&request)
        .send()
        .await?;

    if !response.status().is_success() {
        let status = response.status();
        let text = response.text().await.unwrap_or_default();
        anyhow::bail!("OpenAI API error {status}: {text}");
    }

    let parsed: ChatResponse = response.json().await?;
    Ok(parsed)
}

/// Legacy single-shot JSON-mode chat. No tools, no multi-turn.
/// Returned string is the assistant message content.
pub async fn chat_json(
    api_key: &str,
    model: &str,
    system_prompt: &str,
    messages: &[Value],
) -> anyhow::Result<String> {
    let mut all = Vec::with_capacity(messages.len() + 1);
    all.push(serde_json::json!({"role": "system", "content": system_prompt}));
    all.extend(messages.iter().cloned());

    let request = ChatRequest {
        model,
        messages: all,
        tools: None,
        tool_choice: None,
        response_format: Some(serde_json::json!({"type": "json_object"})),
    };

    let response = chat_with_tools(api_key, request).await?;
    let content = response
        .choices
        .first()
        .and_then(|c| c.message.content.clone())
        .unwrap_or_default();
    Ok(content)
}

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

    #[test]
    fn tool_def_serializes_with_function_type() {
        let tool = ToolDef {
            kind: "function",
            function: ToolFn {
                name: "validate_card",
                description: "Validate a card",
                parameters: serde_json::json!({"type": "object"}),
            },
        };
        let v = serde_json::to_value(&tool).unwrap();
        assert_eq!(v["type"], "function");
        assert_eq!(v["function"]["name"], "validate_card");
    }

    #[test]
    fn assistant_message_parses_tool_calls() {
        let raw = serde_json::json!({
            "content": null,
            "tool_calls": [
                {
                    "id": "call_1",
                    "type": "function",
                    "function": {
                        "name": "validate_card",
                        "arguments": "{\"card\":{}}"
                    }
                }
            ]
        });
        let parsed: AssistantMessage = serde_json::from_value(raw).unwrap();
        assert!(parsed.content.is_none());
        assert_eq!(parsed.tool_calls.len(), 1);
        assert_eq!(parsed.tool_calls[0].function.name, "validate_card");
    }
}