Skip to main content

apollo/providers/
traits.rs

1//! Core Provider trait — defines the interface for LLM backends.
2//! Inspired by ZeroClaw's trait system with simplifications.
3
4use async_trait::async_trait;
5use serde::{Deserialize, Serialize};
6
7use crate::tools::ToolSpec;
8
9/// A single message in a conversation.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ChatMessage {
12    pub role: String,
13    pub content: String,
14    /// For tool_result messages: the tool_use_id this is responding to
15    #[serde(skip_serializing_if = "Option::is_none")]
16    pub tool_use_id: Option<String>,
17}
18
19impl ChatMessage {
20    pub fn system(content: impl Into<String>) -> Self {
21        Self {
22            role: "system".into(),
23            content: content.into(),
24            tool_use_id: None,
25        }
26    }
27    pub fn user(content: impl Into<String>) -> Self {
28        Self {
29            role: "user".into(),
30            content: content.into(),
31            tool_use_id: None,
32        }
33    }
34    pub fn assistant(content: impl Into<String>) -> Self {
35        Self {
36            role: "assistant".into(),
37            content: content.into(),
38            tool_use_id: None,
39        }
40    }
41    pub fn tool_result(id: impl Into<String>, content: impl Into<String>) -> Self {
42        Self {
43            role: "tool_result".into(),
44            content: content.into(),
45            tool_use_id: Some(id.into()),
46        }
47    }
48    pub fn is_tool_result(&self) -> bool {
49        self.role == "tool_result"
50    }
51}
52
53/// A tool call requested by the LLM.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct ToolCall {
56    pub id: String,
57    pub name: String,
58    pub arguments: String,
59}
60
61/// LLM response — text, tool calls, or both.
62#[derive(Debug, Clone, Default)]
63pub struct ChatResponse {
64    pub text: Option<String>,
65    pub tool_calls: Vec<ToolCall>,
66    pub usage: Option<Usage>,
67}
68
69#[derive(Debug, Clone, Default)]
70pub struct Usage {
71    pub input_tokens: u32,
72    pub output_tokens: u32,
73}
74
75impl ChatResponse {
76    pub fn has_tool_calls(&self) -> bool {
77        !self.tool_calls.is_empty()
78    }
79    pub fn text_or_empty(&self) -> &str {
80        self.text.as_deref().unwrap_or("")
81    }
82}
83
84/// Chat request payload.
85#[derive(Debug, Clone, Copy)]
86pub struct ChatRequest<'a> {
87    pub messages: &'a [ChatMessage],
88    pub tools: Option<&'a [ToolSpec]>,
89    pub model: &'a str,
90    pub temperature: f64,
91    pub max_tokens: Option<u32>,
92}
93
94/// Provider capabilities
95#[derive(Debug, Clone, Default)]
96pub struct ProviderCapabilities {
97    /// Supports native tool calling (not prompt-injection)
98    pub native_tools: bool,
99    /// Supports streaming responses
100    pub streaming: bool,
101    /// Supports vision/image input
102    pub vision: bool,
103    /// Maximum context window
104    pub max_context: u32,
105    /// Runs web search on the provider's own infrastructure, so apollo's
106    /// `web_search` tool must be left out of the tool set to avoid a name clash.
107    pub native_web_search: bool,
108}
109
110/// The core Provider trait.
111/// Implement this for each LLM backend (Anthropic, OpenAI, Gemini, Ollama, etc.)
112#[async_trait]
113pub trait Provider: Send + Sync {
114    /// Provider name (e.g., "anthropic", "openai", "ollama")
115    fn name(&self) -> &str;
116
117    /// Query capabilities
118    fn capabilities(&self) -> ProviderCapabilities {
119        ProviderCapabilities::default()
120    }
121
122    /// Send a chat request and get a response.
123    async fn chat(&self, request: &ChatRequest<'_>) -> anyhow::Result<ChatResponse>;
124
125    /// Simple one-shot message (convenience wrapper)
126    async fn simple_chat(&self, message: &str, model: &str) -> anyhow::Result<String> {
127        let messages = [ChatMessage::user(message)];
128        let request = ChatRequest {
129            messages: &messages,
130            tools: None,
131            model,
132            temperature: 0.7,
133            max_tokens: None,
134        };
135        let response = self.chat(&request).await?;
136        Ok(response.text.unwrap_or_default())
137    }
138}