Skip to main content

claude_codex/providers/kimi/translate/
reducer.rs

1use crate::anthropic::sse::parse_sse_events;
2
3#[derive(Debug, Clone)]
4pub struct UpstreamStreamError {
5    pub kind: UpstreamErrorKind,
6    pub message: String,
7    pub retry_after_seconds: Option<u64>,
8}
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum UpstreamErrorKind {
12    RateLimit,
13    Failed,
14}
15
16#[derive(Debug, Clone, Default)]
17pub struct KimiUsage {
18    pub prompt_tokens: Option<u64>,
19    pub completion_tokens: Option<u64>,
20    pub total_tokens: Option<u64>,
21    pub cached_tokens: Option<u64>,
22    pub reasoning_tokens: Option<u64>,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum StopReason {
27    EndTurn,
28    ToolUse,
29    MaxTokens,
30}
31
32#[derive(Debug, Clone)]
33pub enum ReducerEvent {
34    ThinkingStart {
35        index: usize,
36    },
37    ThinkingDelta {
38        index: usize,
39        text: String,
40    },
41    ThinkingStop {
42        index: usize,
43    },
44    TextStart {
45        index: usize,
46    },
47    TextDelta {
48        index: usize,
49        text: String,
50    },
51    TextStop {
52        index: usize,
53    },
54    ToolStart {
55        index: usize,
56        id: String,
57        name: String,
58    },
59    ToolDelta {
60        index: usize,
61        partial_json: String,
62    },
63    ToolStop {
64        index: usize,
65    },
66    Finish {
67        stop_reason: StopReason,
68        usage: Option<KimiUsage>,
69    },
70}
71
72#[derive(Debug, Clone, serde::Deserialize)]
73struct StreamChunk {
74    #[serde(default)]
75    choices: Option<Vec<StreamChoice>>,
76    #[serde(default)]
77    usage: Option<StreamUsage>,
78    #[serde(default)]
79    error: Option<StreamError>,
80}
81
82#[derive(Debug, Clone, serde::Deserialize)]
83struct StreamChoice {
84    #[serde(default)]
85    delta: Option<StreamDelta>,
86    #[serde(default)]
87    finish_reason: Option<String>,
88}
89
90#[derive(Debug, Clone, serde::Deserialize)]
91struct StreamDelta {
92    #[allow(dead_code)]
93    #[serde(default)]
94    role: Option<String>,
95    #[serde(default)]
96    content: Option<String>,
97    #[serde(default)]
98    reasoning_content: Option<String>,
99    #[serde(default)]
100    tool_calls: Option<Vec<StreamToolCall>>,
101}
102
103#[derive(Debug, Clone, serde::Deserialize)]
104struct StreamToolCall {
105    index: usize,
106    #[serde(default)]
107    id: Option<String>,
108    #[allow(dead_code)]
109    #[serde(default)]
110    r#type: Option<String>,
111    #[serde(default)]
112    function: Option<StreamToolCallFunction>,
113}
114
115#[derive(Debug, Clone, serde::Deserialize)]
116struct StreamToolCallFunction {
117    #[serde(default)]
118    name: Option<String>,
119    #[serde(default)]
120    arguments: Option<String>,
121}
122
123#[derive(Debug, Clone, serde::Deserialize)]
124struct StreamUsage {
125    #[serde(default)]
126    prompt_tokens: Option<u64>,
127    #[serde(default)]
128    completion_tokens: Option<u64>,
129    #[serde(default)]
130    total_tokens: Option<u64>,
131    #[serde(default)]
132    cached_tokens: Option<u64>,
133    #[serde(default)]
134    prompt_tokens_details: Option<PromptTokensDetails>,
135    #[serde(default)]
136    completion_tokens_details: Option<CompletionTokensDetails>,
137}
138
139#[derive(Debug, Clone, serde::Deserialize)]
140struct PromptTokensDetails {
141    #[serde(default)]
142    cached_tokens: Option<u64>,
143}
144
145#[derive(Debug, Clone, serde::Deserialize)]
146struct CompletionTokensDetails {
147    #[serde(default)]
148    reasoning_tokens: Option<u64>,
149}
150
151#[derive(Debug, Clone, serde::Deserialize)]
152struct StreamError {
153    #[serde(default)]
154    message: Option<String>,
155    #[allow(dead_code)]
156    #[serde(default)]
157    r#type: Option<String>,
158}
159
160#[allow(dead_code)]
161struct ToolSlot {
162    tc_index: usize,
163    block_index: usize,
164    id: String,
165    name: String,
166}
167
168pub fn reduce_upstream_bytes(input: &[u8]) -> Result<Vec<ReducerEvent>, UpstreamStreamError> {
169    let sse_events = parse_sse_events(input);
170    let mut out = Vec::new();
171    let mut next_block_index = 0usize;
172    let mut thinking_index: Option<usize> = None;
173    let mut text_index: Option<usize> = None;
174    let mut tool_slots: Vec<ToolSlot> = Vec::new();
175    let mut saw_tool_calls = false;
176    let mut finish_reason: Option<String> = None;
177    let mut final_usage: Option<KimiUsage> = None;
178
179    for evt in &sse_events {
180        let data = evt.data.trim();
181        if data.is_empty() || data == "[DONE]" {
182            continue;
183        }
184
185        let chunk: StreamChunk = match serde_json::from_str(data) {
186            Ok(c) => c,
187            Err(_) => continue,
188        };
189
190        if let Some(ref err) = chunk.error {
191            return Err(UpstreamStreamError {
192                kind: UpstreamErrorKind::Failed,
193                message: err
194                    .message
195                    .clone()
196                    .unwrap_or_else(|| "Upstream error".to_string()),
197                retry_after_seconds: None,
198            });
199        }
200
201        if chunk.usage.is_some() && chunk.choices.as_ref().map(|c| c.is_empty()).unwrap_or(true) {
202            final_usage = chunk.usage.map(|u| kimi_usage_from_stream(&u));
203            continue;
204        }
205
206        let choice = match chunk.choices.as_ref().and_then(|c| c.first()) {
207            Some(c) => c,
208            None => continue,
209        };
210        let delta = match choice.delta.as_ref() {
211            Some(d) => d,
212            None => {
213                if choice.finish_reason.is_some() {
214                    finish_reason = choice.finish_reason.clone();
215                    if chunk.usage.is_some() {
216                        final_usage = chunk.usage.map(|u| kimi_usage_from_stream(&u));
217                    }
218                }
219                continue;
220            }
221        };
222
223        // Reasoning content
224        if let Some(ref reasoning) = delta.reasoning_content
225            && !reasoning.is_empty()
226        {
227            if thinking_index.is_none() {
228                thinking_index = Some(next_block_index);
229                next_block_index += 1;
230                out.push(ReducerEvent::ThinkingStart {
231                    index: thinking_index.unwrap(),
232                });
233            }
234            out.push(ReducerEvent::ThinkingDelta {
235                index: thinking_index.unwrap(),
236                text: reasoning.clone(),
237            });
238        }
239
240        // Content text
241        if let Some(ref content) = delta.content
242            && !content.is_empty()
243        {
244            // Close thinking before text
245            if let Some(ti) = thinking_index.take() {
246                out.push(ReducerEvent::ThinkingStop { index: ti });
247            }
248            if text_index.is_none() {
249                text_index = Some(next_block_index);
250                next_block_index += 1;
251                out.push(ReducerEvent::TextStart {
252                    index: text_index.unwrap(),
253                });
254            }
255            out.push(ReducerEvent::TextDelta {
256                index: text_index.unwrap(),
257                text: content.clone(),
258            });
259        }
260
261        // Tool calls
262        if let Some(ref tool_calls) = delta.tool_calls
263            && !tool_calls.is_empty()
264        {
265            // Close thinking and text before tools
266            if let Some(ti) = thinking_index.take() {
267                out.push(ReducerEvent::ThinkingStop { index: ti });
268            }
269            if let Some(ti) = text_index.take() {
270                out.push(ReducerEvent::TextStop { index: ti });
271            }
272
273            for tc in tool_calls {
274                let existing_pos = tool_slots.iter().position(|s| s.tc_index == tc.index);
275                let block_index = if let Some(pos) = existing_pos {
276                    tool_slots[pos].block_index
277                } else {
278                    let id = tc.id.clone().unwrap_or_default();
279                    let name = tc
280                        .function
281                        .as_ref()
282                        .and_then(|f| f.name.clone())
283                        .unwrap_or_default();
284                    if id.is_empty() || name.is_empty() {
285                        continue;
286                    }
287                    saw_tool_calls = true;
288                    let bi = next_block_index;
289                    next_block_index += 1;
290                    tool_slots.push(ToolSlot {
291                        tc_index: tc.index,
292                        block_index: bi,
293                        id: id.clone(),
294                        name: name.clone(),
295                    });
296                    out.push(ReducerEvent::ToolStart {
297                        index: bi,
298                        id,
299                        name,
300                    });
301                    bi
302                };
303
304                if let Some(args) = tc.function.as_ref().and_then(|f| f.arguments.as_ref())
305                    && !args.is_empty()
306                {
307                    out.push(ReducerEvent::ToolDelta {
308                        index: block_index,
309                        partial_json: args.to_string(),
310                    });
311                }
312            }
313        }
314
315        // Finish reason on choice level
316        if let Some(ref reason) = choice.finish_reason {
317            finish_reason = Some(reason.clone());
318            if chunk.usage.is_some() {
319                final_usage = chunk.usage.map(|u| kimi_usage_from_stream(&u));
320            }
321        }
322    }
323
324    // Close any open blocks
325    if let Some(ti) = thinking_index.take() {
326        out.push(ReducerEvent::ThinkingStop { index: ti });
327    }
328    if let Some(ti) = text_index.take() {
329        out.push(ReducerEvent::TextStop { index: ti });
330    }
331    for slot in tool_slots.iter() {
332        out.push(ReducerEvent::ToolStop {
333            index: slot.block_index,
334        });
335    }
336
337    let stop_reason = match finish_reason.as_deref() {
338        Some("length") => StopReason::MaxTokens,
339        Some("tool_calls") => StopReason::ToolUse,
340        _ if saw_tool_calls => StopReason::ToolUse,
341        _ => StopReason::EndTurn,
342    };
343
344    out.push(ReducerEvent::Finish {
345        stop_reason,
346        usage: final_usage,
347    });
348
349    Ok(out)
350}
351
352fn kimi_usage_from_stream(u: &StreamUsage) -> KimiUsage {
353    KimiUsage {
354        prompt_tokens: u.prompt_tokens,
355        completion_tokens: u.completion_tokens,
356        total_tokens: u.total_tokens,
357        cached_tokens: u
358            .prompt_tokens_details
359            .as_ref()
360            .and_then(|d| d.cached_tokens)
361            .or(u.cached_tokens),
362        reasoning_tokens: u
363            .completion_tokens_details
364            .as_ref()
365            .and_then(|d| d.reasoning_tokens),
366    }
367}
368
369pub fn map_usage_to_anthropic(u: &Option<KimiUsage>) -> AnthropicUsage {
370    let usage = match u {
371        Some(u) => u,
372        None => return AnthropicUsage::default(),
373    };
374    let cached = usage.cached_tokens.unwrap_or(0);
375    let total_prompt = usage.prompt_tokens.unwrap_or(0);
376
377    // Subtract cached from input_tokens like reference does
378    let input_tokens = total_prompt.saturating_sub(cached);
379    AnthropicUsage {
380        input_tokens,
381        output_tokens: usage.completion_tokens.unwrap_or(0),
382        cache_creation_input_tokens: 0,
383        cache_read_input_tokens: cached,
384    }
385}
386
387#[derive(Debug, Clone, Default, serde::Serialize)]
388pub struct AnthropicUsage {
389    pub input_tokens: u64,
390    pub output_tokens: u64,
391    pub cache_creation_input_tokens: u64,
392    pub cache_read_input_tokens: u64,
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    #[test]
400    fn reducer_maps_reasoning_text_tool_and_usage() {
401        let upstream = concat!(
402            "data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"think\"}}]}\n\n",
403            "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\n",
404            "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"function\":{\"name\":\"search\",\"arguments\":\"{\\\"q\\\"\"}}]}}}]}\n\n",
405            "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\":\\\"rust\\\"}\"}}]}}]}\n\n",
406            "data: {\"choices\":[{\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":3,\"prompt_tokens_details\":{\"cached_tokens\":4}}}\n\n",
407            "data: [DONE]\n\n"
408        );
409        let events = reduce_upstream_bytes(upstream.as_bytes()).unwrap();
410        assert!(
411            events
412                .iter()
413                .any(|e| matches!(e, ReducerEvent::ThinkingDelta { text, .. } if text == "think"))
414        );
415        assert!(events.iter().any(|e| matches!(
416            e,
417            ReducerEvent::Finish {
418                stop_reason: StopReason::ToolUse,
419                ..
420            }
421        )));
422    }
423
424    #[test]
425    fn reducer_handles_simple_text() {
426        let upstream = concat!(
427            "data: {\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\n",
428            "data: {\"choices\":[{\"delta\":{\"content\":\" world\"}}]}\n\n",
429            "data: {\"choices\":[{\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":2}}\n\n",
430            "data: [DONE]\n\n"
431        );
432        let events = reduce_upstream_bytes(upstream.as_bytes()).unwrap();
433        assert!(
434            events
435                .iter()
436                .any(|e| matches!(e, ReducerEvent::TextStart { .. }))
437        );
438        let texts: Vec<&str> = events
439            .iter()
440            .filter_map(|e| match e {
441                ReducerEvent::TextDelta { text, .. } => Some(text.as_str()),
442                _ => None,
443            })
444            .collect();
445        assert_eq!(texts, vec!["hello", " world"]);
446        assert!(events.iter().any(|e| matches!(
447            e,
448            ReducerEvent::Finish {
449                stop_reason: StopReason::EndTurn,
450                ..
451            }
452        )));
453    }
454
455    #[test]
456    fn reducer_handles_max_tokens() {
457        let upstream = concat!(
458            "data: {\"choices\":[{\"delta\":{\"content\":\"partial\"}}]}\n\n",
459            "data: {\"choices\":[{\"finish_reason\":\"length\"}]}\n\n",
460            "data: [DONE]\n\n"
461        );
462        let events = reduce_upstream_bytes(upstream.as_bytes()).unwrap();
463        assert!(events.iter().any(|e| matches!(
464            e,
465            ReducerEvent::Finish {
466                stop_reason: StopReason::MaxTokens,
467                ..
468            }
469        )));
470    }
471
472    #[test]
473    fn reducer_ignores_invalid_json() {
474        let upstream = concat!(
475            "data: {\"choices\":[{\"delta\":{\"content\":\"valid\"}}]}\n\n",
476            "data: not json\n\n",
477            "data: {\"choices\":[{\"finish_reason\":\"stop\"}]}\n\n",
478        );
479        let events = reduce_upstream_bytes(upstream.as_bytes()).unwrap();
480        let texts: Vec<&str> = events
481            .iter()
482            .filter_map(|e| match e {
483                ReducerEvent::TextDelta { text, .. } => Some(text.as_str()),
484                _ => None,
485            })
486            .collect();
487        assert_eq!(texts, vec!["valid"]);
488    }
489
490    #[test]
491    fn reducer_returns_error_on_upstream_error() {
492        let upstream = "data: {\"error\":{\"message\":\"rate limit exceeded\"}}\n\n";
493        let result = reduce_upstream_bytes(upstream.as_bytes());
494        assert!(result.is_err());
495        assert_eq!(result.unwrap_err().kind, UpstreamErrorKind::Failed);
496    }
497
498    #[test]
499    fn map_usage_subtracts_cached() {
500        let usage = KimiUsage {
501            prompt_tokens: Some(100),
502            completion_tokens: Some(50),
503            cached_tokens: Some(20),
504            ..Default::default()
505        };
506        let mapped = map_usage_to_anthropic(&Some(usage));
507        assert_eq!(mapped.input_tokens, 80);
508        assert_eq!(mapped.output_tokens, 50);
509        assert_eq!(mapped.cache_read_input_tokens, 20);
510    }
511}