Skip to main content

ralph/providers/
deepseek.rs

1/// Native DeepSeek provider with optional extended thinking support.
2///
3/// Default model: deepseek-v4-flash  Context: 1_000_000 tokens
4/// When ThinkingMode::On is set, adds "thinking" block to the API request
5/// and parses reasoning_content / reasoning_tokens from the response.
6use crate::errors::{RalphError, Result};
7use crate::providers::{
8    ContentPart, LlmProvider, LlmResponse, Message, MessageContent, Role, StopReason, ToolCall,
9    ToolDef,
10};
11use async_trait::async_trait;
12use futures_util::StreamExt;
13use serde::{Deserialize, Serialize};
14use serde_json::{json, Value};
15use std::collections::HashMap;
16
17pub const DEFAULT_MODEL: &str = "deepseek-v4-flash";
18pub const PRO_MODEL: &str = "deepseek-v4-pro";
19const CONTEXT_WINDOW: u64 = 1_000_000;
20const BASE_URL: &str = "https://api.deepseek.com/v1/chat/completions";
21const MAX_TOKENS: u32 = 8192;
22const MAX_RETRIES: u32 = 5;
23
24/// Controls extended thinking (reasoning) mode for DeepSeek.
25#[derive(Debug, Clone)]
26pub enum ThinkingMode {
27    Off,
28    On { budget_tokens: u32 },
29}
30
31pub struct DeepSeekProvider {
32    api_key: String,
33    model: String,
34    thinking: ThinkingMode,
35    client: reqwest::Client,
36}
37
38impl DeepSeekProvider {
39    pub fn new(api_key: String, model: Option<String>, thinking: ThinkingMode) -> Self {
40        Self {
41            api_key,
42            model: model.unwrap_or_else(|| DEFAULT_MODEL.to_string()),
43            thinking,
44            client: reqwest::Client::new(),
45        }
46    }
47}
48
49// ── Request / response types ──────────────────────────────────────────────────
50
51#[derive(Serialize)]
52struct DeepSeekRequest {
53    model: String,
54    messages: Vec<OaiMessage>,
55    #[serde(skip_serializing_if = "Vec::is_empty")]
56    tools: Vec<OaiTool>,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    max_tokens: Option<u32>,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    thinking: Option<ThinkingBlock>,
61}
62
63#[derive(Serialize)]
64struct ThinkingBlock {
65    #[serde(rename = "type")]
66    kind: String,
67    budget_tokens: u32,
68}
69
70#[derive(Serialize)]
71struct OaiMessage {
72    role: String,
73    content: Value,
74    #[serde(skip_serializing_if = "Option::is_none")]
75    tool_calls: Option<Vec<OaiToolCallOut>>,
76    #[serde(skip_serializing_if = "Option::is_none")]
77    tool_call_id: Option<String>,
78    #[serde(skip_serializing_if = "Option::is_none")]
79    name: Option<String>,
80    /// DeepSeek thinking mode: echo reasoning_content back on subsequent calls.
81    #[serde(skip_serializing_if = "Option::is_none")]
82    reasoning_content: Option<String>,
83}
84
85#[derive(Serialize, Deserialize)]
86struct OaiToolCallOut {
87    id: String,
88    #[serde(rename = "type")]
89    kind: String,
90    function: OaiFunctionCall,
91}
92
93#[derive(Serialize, Deserialize)]
94struct OaiFunctionCall {
95    name: String,
96    arguments: String,
97}
98
99#[derive(Serialize)]
100struct OaiTool {
101    #[serde(rename = "type")]
102    kind: String,
103    function: OaiToolFunction,
104}
105
106#[derive(Serialize)]
107struct OaiToolFunction {
108    name: String,
109    description: String,
110    parameters: Value,
111}
112
113#[derive(Deserialize)]
114struct DeepSeekResponse {
115    choices: Vec<DsChoice>,
116    usage: DsUsage,
117}
118
119#[derive(Deserialize)]
120struct DsChoice {
121    message: DsRespMessage,
122    finish_reason: Option<String>,
123}
124
125#[derive(Deserialize)]
126struct DsRespMessage {
127    content: Option<String>,
128    #[serde(default)]
129    reasoning_content: Option<String>,
130    tool_calls: Option<Vec<OaiToolCallOut>>,
131}
132
133#[derive(Deserialize)]
134struct DsUsage {
135    prompt_tokens: u64,
136    completion_tokens: u64,
137    #[serde(default)]
138    completion_tokens_details: Option<DsCompletionTokensDetails>,
139}
140
141#[derive(Deserialize)]
142struct DsCompletionTokensDetails {
143    #[serde(default)]
144    reasoning_tokens: u64,
145}
146
147// ── SSE streaming types ───────────────────────────────────────────────────────
148
149#[derive(Deserialize)]
150struct DsStreamChunk {
151    choices: Vec<DsStreamChoice>,
152    #[serde(default)]
153    usage: Option<DsUsage>,
154}
155
156#[derive(Deserialize)]
157struct DsStreamChoice {
158    delta: DsStreamDelta,
159    finish_reason: Option<String>,
160}
161
162#[derive(Deserialize, Default)]
163struct DsStreamDelta {
164    content: Option<String>,
165    #[serde(default)]
166    reasoning_content: Option<String>,
167    tool_calls: Option<Vec<OaiToolCallChunk>>,
168}
169
170#[derive(Deserialize)]
171struct OaiToolCallChunk {
172    index: usize,
173    id: Option<String>,
174    function: Option<OaiFunctionChunk>,
175}
176
177#[derive(Deserialize)]
178struct OaiFunctionChunk {
179    name: Option<String>,
180    arguments: Option<String>,
181}
182
183// ── Message conversion ────────────────────────────────────────────────────────
184
185/// Convert messages to OAI format.
186///
187/// DeepSeek enforces consistency across the entire message history: if ANY assistant
188/// message carries `reasoning_content`, ALL assistant messages must carry it (using an
189/// empty string for turns that didn't produce thinking). Mixing `Some` and `None` in
190/// the same request triggers HTTP 400, regardless of whether thinking is currently
191/// enabled. We therefore detect the "needs normalization" condition once and apply it
192/// uniformly across all messages.
193fn messages_to_oai(messages: &[Message]) -> Vec<OaiMessage> {
194    // Normalize if any assistant message already has reasoning_content set.
195    let normalize = messages
196        .iter()
197        .any(|m| matches!(m.role, Role::Assistant) && m.reasoning_content.is_some());
198
199    messages
200        .iter()
201        .map(|m| {
202            let role = match m.role {
203                Role::System => "system",
204                Role::User => "user",
205                Role::Assistant => "assistant",
206                Role::Tool => "tool",
207            };
208
209            if matches!(m.role, Role::Assistant) {
210                let reasoning_content = if normalize {
211                    // Ensure every assistant message has the field so DeepSeek sees a
212                    // consistent history. Turns that produced no thinking get "".
213                    Some(m.reasoning_content.clone().unwrap_or_default())
214                } else {
215                    None
216                };
217
218                if let MessageContent::Parts(parts) = &m.content {
219                    let text: String = parts
220                        .iter()
221                        .filter_map(|p| match p {
222                            ContentPart::Text { text } => Some(text.as_str()),
223                            _ => None,
224                        })
225                        .collect::<Vec<_>>()
226                        .join("\n");
227
228                    let tool_calls: Vec<OaiToolCallOut> = parts
229                        .iter()
230                        .filter_map(|p| match p {
231                            ContentPart::ToolUse { id, name, input } => Some(OaiToolCallOut {
232                                id: id.clone(),
233                                kind: "function".to_string(),
234                                function: OaiFunctionCall {
235                                    name: name.clone(),
236                                    arguments: serde_json::to_string(input)
237                                        .unwrap_or_else(|_| "{}".to_string()),
238                                },
239                            }),
240                            _ => None,
241                        })
242                        .collect();
243
244                    return OaiMessage {
245                        role: "assistant".to_string(),
246                        content: if text.is_empty() {
247                            json!(null)
248                        } else {
249                            json!(text)
250                        },
251                        tool_calls: if tool_calls.is_empty() {
252                            None
253                        } else {
254                            Some(tool_calls)
255                        },
256                        tool_call_id: None,
257                        name: None,
258                        reasoning_content,
259                    };
260                }
261                // Text-only assistant message (no tool calls).
262                return OaiMessage {
263                    role: "assistant".to_string(),
264                    content: json!(m.content.as_text()),
265                    tool_calls: None,
266                    tool_call_id: None,
267                    name: None,
268                    reasoning_content,
269                };
270            }
271
272            OaiMessage {
273                role: role.to_string(),
274                content: json!(m.content.as_text()),
275                tool_calls: None,
276                tool_call_id: m.tool_call_id.clone(),
277                name: m.name.clone(),
278                reasoning_content: None,
279            }
280        })
281        .collect()
282}
283
284fn build_tool_list(tools: &[ToolDef]) -> Vec<OaiTool> {
285    tools
286        .iter()
287        .map(|t| OaiTool {
288            kind: "function".to_string(),
289            function: OaiToolFunction {
290                name: t.name.clone(),
291                description: t.description.clone(),
292                parameters: t.parameters.clone(),
293            },
294        })
295        .collect()
296}
297
298#[async_trait]
299impl LlmProvider for DeepSeekProvider {
300    async fn chat(&self, messages: &[Message], tools: &[ToolDef]) -> Result<LlmResponse> {
301        let oai_tools = build_tool_list(tools);
302
303        let thinking_block = match &self.thinking {
304            ThinkingMode::Off => None,
305            ThinkingMode::On { budget_tokens } => Some(ThinkingBlock {
306                kind: "enabled".to_string(),
307                budget_tokens: *budget_tokens,
308            }),
309        };
310
311        let body = DeepSeekRequest {
312            model: self.model.clone(),
313            messages: messages_to_oai(messages),
314            tools: oai_tools,
315            max_tokens: Some(MAX_TOKENS),
316            thinking: thinking_block,
317        };
318
319        let mut last_decode_err = String::new();
320        for attempt in 0..MAX_RETRIES {
321            if attempt > 0 {
322                // Exponential backoff: 2s, 4s, 8s, 16s
323                let secs = (2_u64).pow(attempt).min(30);
324                tokio::time::sleep(tokio::time::Duration::from_secs(secs)).await;
325            }
326
327            let resp = self
328                .client
329                .post(BASE_URL)
330                .bearer_auth(&self.api_key)
331                .json(&body)
332                .send()
333                .await?;
334
335            let status = resp.status();
336            if status == 401 {
337                return Err(RalphError::LlmAuth {
338                    provider: "deepseek".to_string(),
339                });
340            }
341            if status == 429 {
342                return Err(RalphError::LlmRateLimit {
343                    provider: "deepseek".to_string(),
344                    attempts: 1,
345                });
346            }
347            if !status.is_success() {
348                let err_body = resp.text().await.unwrap_or_default();
349                // Retry on transient server errors (5xx); fail fast on client errors
350                if status.as_u16() >= 500 {
351                    last_decode_err = format!("HTTP {}: {}", status, err_body);
352                    continue;
353                }
354                return Err(RalphError::LlmApi {
355                    provider: "deepseek".to_string(),
356                    message: format!("HTTP {}: {}", status, err_body),
357                });
358            }
359
360            let parsed: DeepSeekResponse = match resp.json().await {
361                Ok(p) => p,
362                Err(e) => {
363                    last_decode_err = e.to_string();
364                    continue;
365                }
366            };
367
368            let choice = parsed.choices.into_iter().next().ok_or_else(|| {
369                RalphError::LlmResponseParse("No choices in response".to_string())
370            })?;
371
372            let stop_reason = match choice.finish_reason.as_deref() {
373                Some("tool_calls") => StopReason::ToolUse,
374                Some("stop") => StopReason::Stop,
375                Some("length") => StopReason::MaxTokens,
376                _ => StopReason::EndTurn,
377            };
378
379            // For thinking-mode responses, always store Some(...) — even empty — so that
380            // subsequent requests can include it consistently without mixing Some/None.
381            let reasoning_content = match &self.thinking {
382                ThinkingMode::On { .. } => {
383                    Some(choice.message.reasoning_content.clone().unwrap_or_default())
384                }
385                ThinkingMode::Off => choice.message.reasoning_content.clone(),
386            };
387
388            let tool_calls = choice
389                .message
390                .tool_calls
391                .unwrap_or_default()
392                .into_iter()
393                .map(|tc| {
394                    let args: Value = serde_json::from_str(&tc.function.arguments)
395                        .unwrap_or(Value::Object(Default::default()));
396                    ToolCall {
397                        id: tc.id,
398                        name: tc.function.name,
399                        arguments: args,
400                    }
401                })
402                .collect();
403
404            let input_tokens = parsed.usage.prompt_tokens;
405            let reasoning_tokens = parsed
406                .usage
407                .completion_tokens_details
408                .as_ref()
409                .map(|d| d.reasoning_tokens)
410                .unwrap_or(0);
411            let output_tokens = parsed.usage.completion_tokens;
412
413            return Ok(LlmResponse {
414                text: choice.message.content,
415                tool_calls,
416                input_tokens,
417                output_tokens,
418                reasoning_tokens,
419                reasoning_content,
420                tokens_used: input_tokens + output_tokens + reasoning_tokens,
421                stop_reason,
422            });
423        } // end retry loop
424        Err(RalphError::LlmResponseParse(last_decode_err))
425    }
426
427    async fn chat_streaming(
428        &self,
429        messages: &[Message],
430        tools: &[ToolDef],
431        token_tx: &tokio::sync::mpsc::UnboundedSender<String>,
432    ) -> Result<LlmResponse> {
433        let oai_tools = build_tool_list(tools);
434
435        let thinking_block = match &self.thinking {
436            ThinkingMode::Off => None,
437            ThinkingMode::On { budget_tokens } => Some(json!({
438                "type": "enabled",
439                "budget_tokens": budget_tokens
440            })),
441        };
442
443        let mut body = json!({
444            "model": self.model,
445            "messages": messages_to_oai(messages),
446            "stream": true,
447            "stream_options": { "include_usage": true },
448            "max_tokens": MAX_TOKENS,
449        });
450        if !oai_tools.is_empty() {
451            body["tools"] = json!(oai_tools);
452        }
453        if let Some(tb) = thinking_block {
454            body["thinking"] = tb;
455        }
456
457        let mut last_decode_err = String::new();
458        for attempt in 0..MAX_RETRIES {
459            if attempt > 0 {
460                // Exponential backoff: 2s, 4s, 8s, 16s
461                let secs = (2_u64).pow(attempt).min(30);
462                tokio::time::sleep(tokio::time::Duration::from_secs(secs)).await;
463            }
464
465            let resp = self
466                .client
467                .post(BASE_URL)
468                .bearer_auth(&self.api_key)
469                .json(&body)
470                .send()
471                .await?;
472
473            let status = resp.status();
474            if status == 401 {
475                return Err(RalphError::LlmAuth {
476                    provider: "deepseek".to_string(),
477                });
478            }
479            if status == 429 {
480                return Err(RalphError::LlmRateLimit {
481                    provider: "deepseek".to_string(),
482                    attempts: 1,
483                });
484            }
485            if !status.is_success() {
486                let err_body = resp.text().await.unwrap_or_default();
487                // Retry on transient server errors (5xx); fail fast on client errors
488                if status.as_u16() >= 500 {
489                    last_decode_err = format!("HTTP {}: {}", status, err_body);
490                    continue;
491                }
492                return Err(RalphError::LlmApi {
493                    provider: "deepseek".to_string(),
494                    message: format!("HTTP {}: {}", status, err_body),
495                });
496            }
497
498            let mut stream = resp.bytes_stream();
499            let mut buf = String::new();
500            let mut text_parts: Vec<String> = Vec::new();
501            let mut reasoning_parts: Vec<String> = Vec::new();
502            let mut tool_chunks: HashMap<usize, (String, String, String)> = HashMap::new();
503            let mut input_tokens: u64 = 0;
504            let mut output_tokens: u64 = 0;
505            let mut reasoning_tokens: u64 = 0;
506            let mut finish_reason: Option<String> = None;
507            let mut decode_failed = false;
508
509            while let Some(chunk) = stream.next().await {
510                let bytes = match chunk {
511                    Ok(b) => b,
512                    Err(e) => {
513                        last_decode_err = e.to_string();
514                        decode_failed = true;
515                        break;
516                    }
517                };
518                buf.push_str(&String::from_utf8_lossy(&bytes));
519
520                loop {
521                    let Some(pos) = buf.find('\n') else { break };
522                    let line = buf[..pos].trim().to_string();
523                    buf.drain(..pos + 1);
524
525                    if !line.starts_with("data: ") {
526                        continue;
527                    }
528                    let data = line[6..].trim();
529                    if data == "[DONE]" {
530                        break;
531                    }
532
533                    let Ok(parsed) = serde_json::from_str::<DsStreamChunk>(data) else {
534                        continue;
535                    };
536                    if let Some(usage) = parsed.usage {
537                        input_tokens = usage.prompt_tokens;
538                        output_tokens = usage.completion_tokens;
539                        reasoning_tokens = usage
540                            .completion_tokens_details
541                            .as_ref()
542                            .map(|d| d.reasoning_tokens)
543                            .unwrap_or(0);
544                    }
545                    for choice in parsed.choices {
546                        if let Some(r) = choice.finish_reason {
547                            finish_reason = Some(r);
548                        }
549                        // Accumulate reasoning_content silently — must be echoed back to API.
550                        if let Some(rc) = choice.delta.reasoning_content {
551                            if !rc.is_empty() {
552                                reasoning_parts.push(rc);
553                            }
554                        }
555                        if let Some(content) = choice.delta.content {
556                            if !content.is_empty() {
557                                let _ = token_tx.send(content.clone());
558                                text_parts.push(content);
559                            }
560                        }
561                        if let Some(tcs) = choice.delta.tool_calls {
562                            for tc in tcs {
563                                let entry = tool_chunks.entry(tc.index).or_insert_with(|| {
564                                    (String::new(), String::new(), String::new())
565                                });
566                                if let Some(id) = tc.id {
567                                    entry.0 = id;
568                                }
569                                if let Some(func) = tc.function {
570                                    if let Some(name) = func.name {
571                                        entry.1 = name;
572                                    }
573                                    if let Some(args) = func.arguments {
574                                        entry.2.push_str(&args);
575                                    }
576                                }
577                            }
578                        }
579                    }
580                }
581            }
582
583            let text = if text_parts.is_empty() {
584                None
585            } else {
586                Some(text_parts.join(""))
587            };
588            // For thinking-mode responses, always store Some(...) — even empty — so that
589            // subsequent requests can include it consistently without mixing Some/None.
590            let reasoning_content = match &self.thinking {
591                ThinkingMode::On { .. } => Some(reasoning_parts.join("")),
592                ThinkingMode::Off => {
593                    if reasoning_parts.is_empty() {
594                        None
595                    } else {
596                        Some(reasoning_parts.join(""))
597                    }
598                }
599            };
600
601            let mut sorted: Vec<_> = tool_chunks.into_iter().collect();
602            sorted.sort_by_key(|(i, _)| *i);
603            let tool_calls = sorted
604                .into_iter()
605                .map(|(_, (id, name, args))| {
606                    let arguments: Value =
607                        serde_json::from_str(&args).unwrap_or(Value::Object(Default::default()));
608                    ToolCall {
609                        id,
610                        name,
611                        arguments,
612                    }
613                })
614                .collect();
615
616            let stop_reason = match finish_reason.as_deref() {
617                Some("tool_calls") => StopReason::ToolUse,
618                Some("stop") => StopReason::Stop,
619                Some("length") => StopReason::MaxTokens,
620                _ => StopReason::EndTurn,
621            };
622
623            if decode_failed {
624                continue;
625            }
626
627            return Ok(LlmResponse {
628                text,
629                tool_calls,
630                input_tokens,
631                output_tokens,
632                reasoning_tokens,
633                reasoning_content,
634                tokens_used: input_tokens + output_tokens + reasoning_tokens,
635                stop_reason,
636            });
637        } // end retry loop
638        Err(RalphError::LlmApi {
639            provider: "deepseek".to_string(),
640            message: last_decode_err,
641        })
642    }
643
644    fn supports_streaming(&self) -> bool {
645        true
646    }
647
648    fn name(&self) -> &str {
649        "deepseek"
650    }
651    fn context_window(&self) -> u64 {
652        CONTEXT_WINDOW
653    }
654    fn default_model(&self) -> &str {
655        &self.model
656    }
657}