codetether_agent/provider/
moonshot.rs1use 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 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 #[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), output_cost_per_million: Some(2.8), },
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), 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 let temperature = if request.model.contains("k2") {
257 0.6 } 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 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 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 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 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 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}