1use 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#[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#[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 #[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#[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
183fn messages_to_oai(messages: &[Message]) -> Vec<OaiMessage> {
194 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 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 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 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 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 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 } 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 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 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 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 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 } 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}