Skip to main content

codetether_agent/provider/
moonshot.rs

1//! Moonshot AI provider implementation (direct API)
2//!
3//! For Kimi K2.5 and other Moonshot models via api.moonshot.ai
4
5use super::{
6    CompletionRequest, CompletionResponse, ContentPart, FinishReason, Message, ModelInfo, Provider,
7    Role, StreamChunk, ToolDefinition, Usage,
8};
9use anyhow::{Context, Result};
10use async_trait::async_trait;
11use reqwest::Client;
12use serde::Deserialize;
13use serde_json::{Value, json};
14
15pub struct MoonshotProvider {
16    client: Client,
17    api_key: String,
18    base_url: String,
19}
20
21impl MoonshotProvider {
22    pub fn new(api_key: String) -> Result<Self> {
23        Ok(Self {
24            client: Client::new(),
25            api_key,
26            base_url: "https://api.moonshot.ai/v1".to_string(),
27        })
28    }
29
30    fn convert_messages(messages: &[Message]) -> Vec<Value> {
31        messages
32            .iter()
33            .map(|msg| {
34                let role = match msg.role {
35                    Role::System => "system",
36                    Role::User => "user",
37                    Role::Assistant => "assistant",
38                    Role::Tool => "tool",
39                };
40
41                match msg.role {
42                    Role::Tool => {
43                        if let Some(ContentPart::ToolResult {
44                            tool_call_id,
45                            content,
46                        }) = msg.content.first()
47                        {
48                            json!({
49                                "role": "tool",
50                                "tool_call_id": tool_call_id,
51                                "content": content
52                            })
53                        } else {
54                            json!({"role": role, "content": ""})
55                        }
56                    }
57                    Role::Assistant => {
58                        let text: String = msg
59                            .content
60                            .iter()
61                            .filter_map(|p| match p {
62                                ContentPart::Text { text } => Some(text.clone()),
63                                _ => None,
64                            })
65                            .collect::<Vec<_>>()
66                            .join("");
67
68                        let tool_calls: Vec<Value> = msg
69                            .content
70                            .iter()
71                            .filter_map(|p| match p {
72                                ContentPart::ToolCall {
73                                    id,
74                                    name,
75                                    arguments,
76                                } => Some(json!({
77                                    "id": id,
78                                    "type": "function",
79                                    "function": {
80                                        "name": name,
81                                        "arguments": arguments
82                                    }
83                                })),
84                                _ => None,
85                            })
86                            .collect();
87
88                        if tool_calls.is_empty() {
89                            json!({"role": "assistant", "content": text})
90                        } else {
91                            // Moonshot requires reasoning_content for K2.5 thinking models
92                            // Include empty string when we don't have the original
93                            json!({
94                                "role": "assistant",
95                                "content": if text.is_empty() { "".to_string() } else { text },
96                                "reasoning_content": "",
97                                "tool_calls": tool_calls
98                            })
99                        }
100                    }
101                    _ => {
102                        let text: String = msg
103                            .content
104                            .iter()
105                            .filter_map(|p| match p {
106                                ContentPart::Text { text } => Some(text.clone()),
107                                _ => None,
108                            })
109                            .collect::<Vec<_>>()
110                            .join("\n");
111
112                        json!({"role": role, "content": text})
113                    }
114                }
115            })
116            .collect()
117    }
118
119    fn convert_tools(tools: &[ToolDefinition]) -> Vec<Value> {
120        tools
121            .iter()
122            .map(|t| {
123                json!({
124                    "type": "function",
125                    "function": {
126                        "name": t.name,
127                        "description": t.description,
128                        "parameters": t.parameters
129                    }
130                })
131            })
132            .collect()
133    }
134}
135
136#[derive(Debug, Deserialize)]
137struct MoonshotResponse {
138    id: String,
139    model: String,
140    choices: Vec<MoonshotChoice>,
141    #[serde(default)]
142    usage: Option<MoonshotUsage>,
143}
144
145#[derive(Debug, Deserialize)]
146struct MoonshotChoice {
147    message: MoonshotMessage,
148    #[serde(default)]
149    finish_reason: Option<String>,
150}
151
152#[derive(Debug, Deserialize)]
153struct MoonshotMessage {
154    #[allow(dead_code)]
155    role: String,
156    #[serde(default)]
157    content: Option<String>,
158    #[serde(default)]
159    tool_calls: Option<Vec<MoonshotToolCall>>,
160    // Kimi K2.5 reasoning
161    #[serde(default)]
162    reasoning_content: Option<String>,
163}
164
165#[derive(Debug, Deserialize)]
166struct MoonshotToolCall {
167    id: String,
168    #[serde(rename = "type")]
169    call_type: String,
170    function: MoonshotFunction,
171}
172
173#[derive(Debug, Deserialize)]
174struct MoonshotFunction {
175    name: String,
176    arguments: String,
177}
178
179#[derive(Debug, Deserialize)]
180struct MoonshotUsage {
181    #[serde(default)]
182    prompt_tokens: usize,
183    #[serde(default)]
184    completion_tokens: usize,
185    #[serde(default)]
186    total_tokens: usize,
187}
188
189#[derive(Debug, Deserialize)]
190struct MoonshotError {
191    #[allow(dead_code)]
192    error: MoonshotErrorDetail,
193}
194
195#[derive(Debug, Deserialize)]
196struct MoonshotErrorDetail {
197    message: String,
198    #[serde(default, rename = "type")]
199    error_type: Option<String>,
200}
201
202#[async_trait]
203impl Provider for MoonshotProvider {
204    fn name(&self) -> &str {
205        "moonshotai"
206    }
207
208    async fn list_models(&self) -> Result<Vec<ModelInfo>> {
209        Ok(vec![
210            ModelInfo {
211                id: "kimi-k2.5".to_string(),
212                name: "Kimi K2.5".to_string(),
213                provider: "moonshotai".to_string(),
214                context_window: 256_000,
215                max_output_tokens: Some(64_000),
216                supports_vision: true,
217                supports_tools: true,
218                supports_streaming: true,
219                input_cost_per_million: Some(0.56), // ¥4/M tokens
220                output_cost_per_million: Some(2.8), // ¥20/M tokens
221            },
222            ModelInfo {
223                id: "kimi-k2-thinking".to_string(),
224                name: "Kimi K2 Thinking".to_string(),
225                provider: "moonshotai".to_string(),
226                context_window: 128_000,
227                max_output_tokens: Some(64_000),
228                supports_vision: false,
229                supports_tools: true,
230                supports_streaming: true,
231                input_cost_per_million: Some(0.56),
232                output_cost_per_million: Some(2.8),
233            },
234            ModelInfo {
235                id: "kimi-latest".to_string(),
236                name: "Kimi Latest".to_string(),
237                provider: "moonshotai".to_string(),
238                context_window: 128_000,
239                max_output_tokens: Some(64_000),
240                supports_vision: false,
241                supports_tools: true,
242                supports_streaming: true,
243                input_cost_per_million: Some(0.42), // Cheaper
244                output_cost_per_million: Some(1.68),
245            },
246        ])
247    }
248
249    async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse> {
250        let messages = Self::convert_messages(&request.messages);
251        let tools = Self::convert_tools(&request.tools);
252
253        // Kimi K2.5 requires specific temperatures:
254        // - temperature = 1.0 when thinking is enabled
255        // - temperature = 0.6 when thinking is disabled
256        let temperature = if request.model.contains("k2") {
257            0.6 // We disable thinking for tool calling workflows
258        } else {
259            request.temperature.unwrap_or(0.7)
260        };
261
262        let mut body = json!({
263            "model": request.model,
264            "messages": messages,
265            "temperature": temperature,
266        });
267
268        // Disable thinking mode to avoid needing to track reasoning_content
269        // across message roundtrips (required for K2.5)
270        if request.model.contains("k2") {
271            body["thinking"] = json!({"type": "disabled"});
272        }
273
274        if !tools.is_empty() {
275            body["tools"] = json!(tools);
276        }
277        if let Some(max) = request.max_tokens {
278            body["max_tokens"] = json!(max);
279        }
280
281        tracing::debug!("Moonshot request to model {}", request.model);
282
283        let response = self
284            .client
285            .post(format!("{}/chat/completions", self.base_url))
286            .header("Authorization", format!("Bearer {}", self.api_key))
287            .header("Content-Type", "application/json")
288            .json(&body)
289            .send()
290            .await
291            .context("Failed to send request to Moonshot")?;
292
293        let status = response.status();
294        let text = response.text().await.context("Failed to read response")?;
295
296        if !status.is_success() {
297            if let Ok(err) = serde_json::from_str::<MoonshotError>(&text) {
298                anyhow::bail!(
299                    "Moonshot API error: {} ({:?})",
300                    err.error.message,
301                    err.error.error_type
302                );
303            }
304            anyhow::bail!("Moonshot API error: {} {}", status, text);
305        }
306
307        let response: MoonshotResponse = serde_json::from_str(&text).context(format!(
308            "Failed to parse Moonshot response: {}",
309            &text[..text.len().min(200)]
310        ))?;
311
312        // Log response metadata for debugging
313        tracing::debug!(
314            response_id = %response.id,
315            model = %response.model,
316            "Received Moonshot response"
317        );
318
319        let choice = response
320            .choices
321            .first()
322            .ok_or_else(|| anyhow::anyhow!("No choices"))?;
323
324        // Log reasoning/thinking content if present (Kimi K2 models)
325        if let Some(ref reasoning) = choice.message.reasoning_content {
326            if !reasoning.is_empty() {
327                tracing::info!(
328                    reasoning_len = reasoning.len(),
329                    "Model reasoning/thinking content received"
330                );
331                tracing::debug!(
332                    reasoning = %reasoning,
333                    "Full model reasoning"
334                );
335            }
336        }
337
338        let mut content = Vec::new();
339        let mut has_tool_calls = false;
340
341        if let Some(text) = &choice.message.content {
342            if !text.is_empty() {
343                content.push(ContentPart::Text { text: text.clone() });
344            }
345        }
346
347        if let Some(tool_calls) = &choice.message.tool_calls {
348            has_tool_calls = !tool_calls.is_empty();
349            for tc in tool_calls {
350                // Log tool call details for debugging (uses role and call_type fields)
351                tracing::debug!(
352                    tool_call_id = %tc.id,
353                    call_type = %tc.call_type,
354                    function_name = %tc.function.name,
355                    "Processing tool call"
356                );
357                content.push(ContentPart::ToolCall {
358                    id: tc.id.clone(),
359                    name: tc.function.name.clone(),
360                    arguments: tc.function.arguments.clone(),
361                });
362            }
363        }
364
365        let finish_reason = if has_tool_calls {
366            FinishReason::ToolCalls
367        } else {
368            match choice.finish_reason.as_deref() {
369                Some("stop") => FinishReason::Stop,
370                Some("length") => FinishReason::Length,
371                Some("tool_calls") => FinishReason::ToolCalls,
372                _ => FinishReason::Stop,
373            }
374        };
375
376        Ok(CompletionResponse {
377            message: Message {
378                role: Role::Assistant,
379                content,
380            },
381            usage: Usage {
382                prompt_tokens: response
383                    .usage
384                    .as_ref()
385                    .map(|u| u.prompt_tokens)
386                    .unwrap_or(0),
387                completion_tokens: response
388                    .usage
389                    .as_ref()
390                    .map(|u| u.completion_tokens)
391                    .unwrap_or(0),
392                total_tokens: response.usage.as_ref().map(|u| u.total_tokens).unwrap_or(0),
393                ..Default::default()
394            },
395            finish_reason,
396        })
397    }
398
399    async fn complete_stream(
400        &self,
401        request: CompletionRequest,
402    ) -> Result<futures::stream::BoxStream<'static, StreamChunk>> {
403        tracing::debug!(
404            provider = "moonshotai",
405            model = %request.model,
406            message_count = request.messages.len(),
407            "Starting streaming completion request (falling back to non-streaming)"
408        );
409
410        // Fall back to non-streaming for now
411        let response = self.complete(request).await?;
412        let text = response
413            .message
414            .content
415            .iter()
416            .filter_map(|p| match p {
417                ContentPart::Text { text } => Some(text.clone()),
418                _ => None,
419            })
420            .collect::<Vec<_>>()
421            .join("");
422
423        Ok(Box::pin(futures::stream::once(async move {
424            StreamChunk::Text(text)
425        })))
426    }
427}