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};
6use std::collections::BTreeSet;
7
8use crate::tools::ToolSpec;
9
10/// A single message in a conversation.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct ChatMessage {
13    pub role: String,
14    pub content: String,
15    /// For tool_result messages: the tool_use_id this is responding to
16    #[serde(skip_serializing_if = "Option::is_none")]
17    pub tool_use_id: Option<String>,
18}
19
20impl ChatMessage {
21    pub fn system(content: impl Into<String>) -> Self {
22        Self {
23            role: "system".into(),
24            content: content.into(),
25            tool_use_id: None,
26        }
27    }
28    pub fn user(content: impl Into<String>) -> Self {
29        Self {
30            role: "user".into(),
31            content: content.into(),
32            tool_use_id: None,
33        }
34    }
35    pub fn assistant(content: impl Into<String>) -> Self {
36        Self {
37            role: "assistant".into(),
38            content: content.into(),
39            tool_use_id: None,
40        }
41    }
42    pub fn tool_result(id: impl Into<String>, content: impl Into<String>) -> Self {
43        Self {
44            role: "tool_result".into(),
45            content: content.into(),
46            tool_use_id: Some(id.into()),
47        }
48    }
49    pub fn is_tool_result(&self) -> bool {
50        self.role == "tool_result"
51    }
52}
53
54/// A tool call requested by the LLM.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct ToolCall {
57    pub id: String,
58    pub name: String,
59    pub arguments: String,
60}
61
62/// LLM response — text, tool calls, or both.
63#[derive(Debug, Clone, Default)]
64pub struct ChatResponse {
65    pub text: Option<String>,
66    pub tool_calls: Vec<ToolCall>,
67    pub usage: Option<Usage>,
68}
69
70#[derive(Debug, Clone, Default)]
71pub struct Usage {
72    pub input_tokens: u32,
73    pub output_tokens: u32,
74}
75
76/// Provider-advertised metadata for one model.
77///
78/// Providers are allowed to return sparse model objects. Optional limits and
79/// pricing therefore remain unknown instead of being replaced with guesses.
80#[derive(Debug, Clone, Default, Serialize, Deserialize)]
81pub struct ModelInfo {
82    pub id: String,
83    pub provider: String,
84    pub display_name: String,
85    #[serde(default)]
86    pub description: Option<String>,
87    #[serde(default)]
88    pub capabilities: BTreeSet<String>,
89    #[serde(default)]
90    pub input_modalities: Vec<String>,
91    #[serde(default)]
92    pub output_modalities: Vec<String>,
93    #[serde(default)]
94    pub supported_parameters: BTreeSet<String>,
95    #[serde(default)]
96    pub context_window: Option<u64>,
97    #[serde(default)]
98    pub max_output_tokens: Option<u64>,
99    #[serde(default)]
100    pub pricing: Option<ModelPricing>,
101}
102
103#[derive(Debug, Clone, Default, Serialize, Deserialize)]
104pub struct ModelPricing {
105    pub input_per_token: Option<f64>,
106    pub output_per_token: Option<f64>,
107    pub request: Option<f64>,
108    pub image_input: Option<f64>,
109    pub reasoning: Option<f64>,
110    pub cache_read: Option<f64>,
111    pub cache_write: Option<f64>,
112}
113
114impl ChatResponse {
115    pub fn has_tool_calls(&self) -> bool {
116        !self.tool_calls.is_empty()
117    }
118    pub fn text_or_empty(&self) -> &str {
119        self.text.as_deref().unwrap_or("")
120    }
121}
122
123/// Chat request payload.
124#[derive(Debug, Clone, Copy)]
125pub struct ChatRequest<'a> {
126    pub messages: &'a [ChatMessage],
127    pub tools: Option<&'a [ToolSpec]>,
128    pub model: &'a str,
129    pub temperature: f64,
130    pub max_tokens: Option<u32>,
131}
132
133/// Provider capabilities
134#[derive(Debug, Clone, Default)]
135pub struct ProviderCapabilities {
136    /// Supports native tool calling (not prompt-injection)
137    pub native_tools: bool,
138    /// Supports streaming responses
139    pub streaming: bool,
140    /// Supports vision/image input
141    pub vision: bool,
142    /// Maximum context window
143    pub max_context: u32,
144    /// Runs web search on the provider's own infrastructure, so apollo's
145    /// `web_search` tool must be left out of the tool set to avoid a name clash.
146    pub native_web_search: bool,
147}
148
149/// The core Provider trait.
150/// Implement this for each LLM backend (Anthropic, OpenAI, Gemini, Ollama, etc.)
151#[async_trait]
152pub trait Provider: Send + Sync {
153    /// Provider name (e.g., "anthropic", "openai", "ollama")
154    fn name(&self) -> &str;
155
156    /// Query capabilities
157    fn capabilities(&self) -> ProviderCapabilities {
158        ProviderCapabilities::default()
159    }
160
161    /// Discover the provider's current model snapshot.
162    ///
163    /// Implementations that do not expose a model endpoint can keep the
164    /// default empty result. Consumers should treat an empty result as
165    /// "not advertised", not as proof that the provider has no models.
166    async fn list_models(&self) -> anyhow::Result<Vec<ModelInfo>> {
167        Ok(Vec::new())
168    }
169
170    /// Send a chat request and get a response.
171    async fn chat(&self, request: &ChatRequest<'_>) -> anyhow::Result<ChatResponse>;
172
173    /// Simple one-shot message (convenience wrapper)
174    async fn simple_chat(&self, message: &str, model: &str) -> anyhow::Result<String> {
175        let messages = [ChatMessage::user(message)];
176        let request = ChatRequest {
177            messages: &messages,
178            tools: None,
179            model,
180            temperature: 0.7,
181            max_tokens: None,
182        };
183        let response = self.chat(&request).await?;
184        Ok(response.text.unwrap_or_default())
185    }
186}