Skip to main content

ralph/providers/
mod.rs

1pub mod deepseek;
2mod openai;
3
4use crate::errors::Result;
5use async_trait::async_trait;
6use serde::{Deserialize, Serialize};
7
8// ── Shared message types ─────────────────────────────────────────────────────
9
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
11#[serde(rename_all = "lowercase")]
12pub enum Role {
13    System,
14    User,
15    Assistant,
16    Tool,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct Message {
21    pub role: Role,
22    pub content: MessageContent,
23    /// Tool call id — only set on Role::Tool result messages.
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub tool_call_id: Option<String>,
26    /// Name — only set on Role::Tool result messages.
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub name: Option<String>,
29    /// DeepSeek thinking content — must be echoed back on subsequent API calls.
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub reasoning_content: Option<String>,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35#[serde(untagged)]
36pub enum MessageContent {
37    Text(String),
38    Parts(Vec<ContentPart>),
39}
40
41impl MessageContent {
42    pub fn as_text(&self) -> String {
43        match self {
44            MessageContent::Text(s) => s.clone(),
45            MessageContent::Parts(parts) => parts
46                .iter()
47                .filter_map(|p| match p {
48                    ContentPart::Text { text } => Some(text.as_str()),
49                    _ => None,
50                })
51                .collect::<Vec<_>>()
52                .join("\n"),
53        }
54    }
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
58#[serde(tag = "type", rename_all = "snake_case")]
59pub enum ContentPart {
60    Text {
61        text: String,
62    },
63    ToolUse {
64        id: String,
65        name: String,
66        input: serde_json::Value,
67    },
68    ToolResult {
69        tool_use_id: String,
70        content: String,
71    },
72}
73
74impl Message {
75    pub fn system(content: impl Into<String>) -> Self {
76        Self {
77            role: Role::System,
78            content: MessageContent::Text(content.into()),
79            tool_call_id: None,
80            name: None,
81            reasoning_content: None,
82        }
83    }
84
85    pub fn user(content: impl Into<String>) -> Self {
86        Self {
87            role: Role::User,
88            content: MessageContent::Text(content.into()),
89            tool_call_id: None,
90            name: None,
91            reasoning_content: None,
92        }
93    }
94
95    pub fn assistant(content: impl Into<String>) -> Self {
96        Self {
97            role: Role::Assistant,
98            content: MessageContent::Text(content.into()),
99            tool_call_id: None,
100            name: None,
101            reasoning_content: None,
102        }
103    }
104
105    pub fn tool_result(
106        tool_call_id: impl Into<String>,
107        name: impl Into<String>,
108        result: impl Into<String>,
109    ) -> Self {
110        Self {
111            role: Role::Tool,
112            content: MessageContent::Text(result.into()),
113            tool_call_id: Some(tool_call_id.into()),
114            name: Some(name.into()),
115            reasoning_content: None,
116        }
117    }
118}
119
120// ── Tool definitions ─────────────────────────────────────────────────────────
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct ToolDef {
124    pub name: String,
125    pub description: String,
126    pub parameters: serde_json::Value,
127}
128
129// ── Tool calls returned by the LLM ──────────────────────────────────────────
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct ToolCall {
133    pub id: String,
134    pub name: String,
135    pub arguments: serde_json::Value,
136}
137
138// ── LLM response ─────────────────────────────────────────────────────────────
139
140#[derive(Debug, Clone)]
141pub struct LlmResponse {
142    /// Text the model produced (may be empty if only tool calls were made).
143    pub text: Option<String>,
144    /// Tool calls the model wants to make.
145    pub tool_calls: Vec<ToolCall>,
146    /// Total tokens used (input + output + reasoning) — kept for compat.
147    pub tokens_used: u64,
148    /// Input (prompt) tokens.
149    pub input_tokens: u64,
150    /// Output (completion) tokens.
151    pub output_tokens: u64,
152    /// Reasoning/thinking tokens (DeepSeek extended thinking).
153    pub reasoning_tokens: u64,
154    /// Raw reasoning content text (DeepSeek thinking mode) — must be echoed back.
155    pub reasoning_content: Option<String>,
156    /// Stop reason.
157    pub stop_reason: StopReason,
158}
159
160#[derive(Debug, Clone, PartialEq)]
161pub enum StopReason {
162    EndTurn,
163    ToolUse,
164    MaxTokens,
165    Stop,
166}
167
168// ── Provider trait ────────────────────────────────────────────────────────────
169
170#[async_trait]
171pub trait LlmProvider: Send + Sync {
172    /// Send messages + tool definitions, receive a response.
173    async fn chat(&self, messages: &[Message], tools: &[ToolDef]) -> Result<LlmResponse>;
174
175    /// Stream text tokens via `token_tx` while accumulating the full response.
176    /// The default falls back to `chat()` and delivers the entire text as one chunk.
177    async fn chat_streaming(
178        &self,
179        messages: &[Message],
180        tools: &[ToolDef],
181        token_tx: &tokio::sync::mpsc::UnboundedSender<String>,
182    ) -> Result<LlmResponse> {
183        let resp = self.chat(messages, tools).await?;
184        if let Some(ref text) = resp.text {
185            if !text.is_empty() {
186                let _ = token_tx.send(text.clone());
187            }
188        }
189        Ok(resp)
190    }
191
192    /// Whether this provider supports true token-by-token streaming.
193    /// Providers that override `chat_streaming` with SSE should return true.
194    fn supports_streaming(&self) -> bool {
195        false
196    }
197
198    /// Human-readable name for this provider.
199    fn name(&self) -> &str;
200
201    /// Context window size in tokens.
202    fn context_window(&self) -> u64;
203
204    /// Default model name.
205    fn default_model(&self) -> &str;
206}
207
208// ── Retry helper ─────────────────────────────────────────────────────────────
209
210pub async fn with_retry<F, Fut>(
211    provider_name: &str,
212    max_attempts: u32,
213    mut f: F,
214) -> Result<LlmResponse>
215where
216    F: FnMut() -> Fut,
217    Fut: std::future::Future<Output = Result<LlmResponse>>,
218{
219    use crate::errors::RalphError;
220    let mut delay = std::time::Duration::from_millis(500);
221    for attempt in 1..=max_attempts {
222        match f().await {
223            Ok(r) => return Ok(r),
224            Err(RalphError::LlmRateLimit { .. }) | Err(RalphError::LlmApi { .. })
225                if attempt < max_attempts =>
226            {
227                tokio::time::sleep(delay).await;
228                delay *= 2;
229            }
230            Err(e) => return Err(e),
231        }
232    }
233    Err(RalphError::LlmRateLimit {
234        provider: provider_name.to_string(),
235        attempts: max_attempts,
236    })
237}