greentic-designer 0.6.0

Greentic Designer — orchestrator that powers Adaptive Card design via the adaptive-card-mcp toolkit
Documentation
//! LLM provider abstraction — trait + shared types.
//!
//! Both OpenAI and Anthropic implementations map their responses into
//! the shared `ChatResponse` / `AssistantMessage` types so the chat
//! loop in `routes/chat.rs` stays provider-agnostic.

pub mod anthropic;
pub mod openai;

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

/// Tool definition passed to the LLM. Both OpenAI and Anthropic accept
/// function-calling tool schemas in compatible formats.
#[derive(Serialize, Clone)]
pub struct ToolDef {
    #[serde(rename = "type")]
    pub kind: &'static str,
    pub function: ToolFn,
}

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

/// Shared response types — both providers map into these.
#[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 {
    #[serde(default = "default_assistant_role")]
    pub role: String,
    #[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,
    pub arguments: String,
}

/// Provider-agnostic LLM interface.
pub trait LlmProvider: Send + Sync {
    fn chat_with_tools(
        &self,
        messages: Vec<Value>,
        tools: &[ToolDef],
        tool_choice: Option<&str>,
    ) -> impl std::future::Future<Output = anyhow::Result<ChatResponse>> + Send;
}

/// Enum dispatch over concrete LLM providers.
///
/// Using an enum instead of `Box<dyn LlmProvider>` avoids the
/// object-safety issue with `impl Future` return types.
pub enum LlmBackend {
    OpenAi(openai::OpenAiProvider),
    Anthropic(anthropic::AnthropicProvider),
}

impl LlmProvider for LlmBackend {
    async fn chat_with_tools(
        &self,
        messages: Vec<Value>,
        tools: &[ToolDef],
        tool_choice: Option<&str>,
    ) -> anyhow::Result<ChatResponse> {
        match self {
            Self::OpenAi(p) => p.chat_with_tools(messages, tools, tool_choice).await,
            Self::Anthropic(p) => p.chat_with_tools(messages, tools, tool_choice).await,
        }
    }
}

/// Legacy single-shot chat. Default implementation uses
/// `chat_with_tools` with no tools — works for any provider.
pub async fn legacy_chat_json(
    provider: &LlmBackend,
    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 response = provider.chat_with_tools(all, &[], None).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: "test_tool",
                description: "A test",
                parameters: serde_json::json!({"type": "object"}),
            },
        };
        let v = serde_json::to_value(&tool).unwrap();
        assert_eq!(v["type"], "function");
        assert_eq!(v["function"]["name"], "test_tool");
    }

    #[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": "{}" }
            }]
        });
        let parsed: AssistantMessage = serde_json::from_value(raw).unwrap();
        assert!(parsed.content.is_none());
        assert_eq!(parsed.tool_calls.len(), 1);
    }

    #[test]
    fn assistant_message_parses_text_only() {
        let raw = serde_json::json!({
            "role": "assistant",
            "content": "Hello"
        });
        let parsed: AssistantMessage = serde_json::from_value(raw).unwrap();
        assert_eq!(parsed.content.as_deref(), Some("Hello"));
        assert!(parsed.tool_calls.is_empty());
    }
}