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    block_index: usize,
163    id: String,
164    name: String,
165}
166
167pub fn reduce_upstream_bytes(input: &[u8]) -> Result<Vec<ReducerEvent>, UpstreamStreamError> {
168    let sse_events = parse_sse_events(input);
169    let mut out = Vec::new();
170    let mut next_block_index = 0usize;
171    let mut thinking_index: Option<usize> = None;
172    let mut text_index: Option<usize> = None;
173    let mut tool_slots: Vec<ToolSlot> = Vec::new();
174    let mut saw_tool_calls = false;
175    let mut finish_reason: Option<String> = None;
176    let mut final_usage: Option<KimiUsage> = None;
177
178    for evt in &sse_events {
179        let data = evt.data.trim();
180        if data.is_empty() || data == "[DONE]" {
181            continue;
182        }
183
184        let chunk: StreamChunk = match serde_json::from_str(data) {
185            Ok(c) => c,
186            Err(_) => continue,
187        };
188
189        if let Some(ref err) = chunk.error {
190            return Err(UpstreamStreamError {
191                kind: UpstreamErrorKind::Failed,
192                message: err
193                    .message
194                    .clone()
195                    .unwrap_or_else(|| "Upstream error".to_string()),
196                retry_after_seconds: None,
197            });
198        }
199
200        if chunk.usage.is_some() && chunk.choices.as_ref().map(|c| c.is_empty()).unwrap_or(true) {
201            final_usage = chunk.usage.map(|u| kimi_usage_from_stream(&u));
202            continue;
203        }
204
205        let choice = match chunk.choices.as_ref().and_then(|c| c.first()) {
206            Some(c) => c,
207            None => continue,
208        };
209        let delta = match choice.delta.as_ref() {
210            Some(d) => d,
211            None => {
212                if choice.finish_reason.is_some() {
213                    finish_reason = choice.finish_reason.clone();
214                    if chunk.usage.is_some() {
215                        final_usage = chunk.usage.map(|u| kimi_usage_from_stream(&u));
216                    }
217                }
218                continue;
219            }
220        };
221
222        // Reasoning content
223        if let Some(ref reasoning) = delta.reasoning_content
224            && !reasoning.is_empty()
225        {
226            if thinking_index.is_none() {
227                thinking_index = Some(next_block_index);
228                next_block_index += 1;
229                out.push(ReducerEvent::ThinkingStart {
230                    index: thinking_index.unwrap(),
231                });
232            }
233            out.push(ReducerEvent::ThinkingDelta {
234                index: thinking_index.unwrap(),
235                text: reasoning.clone(),
236            });
237        }
238
239        // Content text
240        if let Some(ref content) = delta.content
241            && !content.is_empty()
242        {
243            // Close thinking before text
244            if let Some(ti) = thinking_index.take() {
245                out.push(ReducerEvent::ThinkingStop { index: ti });
246            }
247            if text_index.is_none() {
248                text_index = Some(next_block_index);
249                next_block_index += 1;
250                out.push(ReducerEvent::TextStart {
251                    index: text_index.unwrap(),
252                });
253            }
254            out.push(ReducerEvent::TextDelta {
255                index: text_index.unwrap(),
256                text: content.clone(),
257            });
258        }
259
260        // Tool calls
261        if let Some(ref tool_calls) = delta.tool_calls
262            && !tool_calls.is_empty()
263        {
264            // Close thinking and text before tools
265            if let Some(ti) = thinking_index.take() {
266                out.push(ReducerEvent::ThinkingStop { index: ti });
267            }
268            if let Some(ti) = text_index.take() {
269                out.push(ReducerEvent::TextStop { index: ti });
270            }
271
272            for tc in tool_calls {
273                let existing_pos = tool_slots.iter().position(|s| s.block_index == tc.index);
274                let block_index = if let Some(pos) = existing_pos {
275                    tool_slots[pos].block_index
276                } else {
277                    let id = tc.id.clone().unwrap_or_default();
278                    let name = tc
279                        .function
280                        .as_ref()
281                        .and_then(|f| f.name.clone())
282                        .unwrap_or_default();
283                    if id.is_empty() || name.is_empty() {
284                        // Defensive: skip out-of-order fragments
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                        block_index: bi,
292                        id: id.clone(),
293                        name: name.clone(),
294                    });
295                    out.push(ReducerEvent::ToolStart {
296                        index: bi,
297                        id,
298                        name,
299                    });
300                    bi
301                };
302
303                if let Some(args) = tc.function.as_ref().and_then(|f| f.arguments.as_ref())
304                    && !args.is_empty()
305                {
306                    out.push(ReducerEvent::ToolDelta {
307                        index: block_index,
308                        partial_json: args.to_string(),
309                    });
310                }
311            }
312        }
313
314        // Finish reason on choice level
315        if let Some(ref reason) = choice.finish_reason {
316            finish_reason = Some(reason.clone());
317            if chunk.usage.is_some() {
318                final_usage = chunk.usage.map(|u| kimi_usage_from_stream(&u));
319            }
320        }
321    }
322
323    // Close any open blocks
324    if let Some(ti) = thinking_index.take() {
325        out.push(ReducerEvent::ThinkingStop { index: ti });
326    }
327    if let Some(ti) = text_index.take() {
328        out.push(ReducerEvent::TextStop { index: ti });
329    }
330    for slot in tool_slots.iter() {
331        out.push(ReducerEvent::ToolStop {
332            index: slot.block_index,
333        });
334    }
335
336    let stop_reason = match finish_reason.as_deref() {
337        Some("length") => StopReason::MaxTokens,
338        Some("tool_calls") => StopReason::ToolUse,
339        _ if saw_tool_calls => StopReason::ToolUse,
340        _ => StopReason::EndTurn,
341    };
342
343    out.push(ReducerEvent::Finish {
344        stop_reason,
345        usage: final_usage,
346    });
347
348    Ok(out)
349}
350
351fn kimi_usage_from_stream(u: &StreamUsage) -> KimiUsage {
352    KimiUsage {
353        prompt_tokens: u.prompt_tokens,
354        completion_tokens: u.completion_tokens,
355        total_tokens: u.total_tokens,
356        cached_tokens: u
357            .prompt_tokens_details
358            .as_ref()
359            .and_then(|d| d.cached_tokens)
360            .or(u.cached_tokens),
361        reasoning_tokens: u
362            .completion_tokens_details
363            .as_ref()
364            .and_then(|d| d.reasoning_tokens),
365    }
366}
367
368pub fn map_usage_to_anthropic(u: &Option<KimiUsage>) -> AnthropicUsage {
369    let usage = match u {
370        Some(u) => u,
371        None => return AnthropicUsage::default(),
372    };
373    let cached = usage.cached_tokens.unwrap_or(0);
374    let total_prompt = usage.prompt_tokens.unwrap_or(0);
375
376    // Subtract cached from input_tokens like reference does
377    let input_tokens = total_prompt.saturating_sub(cached);
378    AnthropicUsage {
379        input_tokens,
380        output_tokens: usage.completion_tokens.unwrap_or(0),
381        cache_creation_input_tokens: 0,
382        cache_read_input_tokens: cached,
383    }
384}
385
386#[derive(Debug, Clone, Default, serde::Serialize)]
387pub struct AnthropicUsage {
388    pub input_tokens: u64,
389    pub output_tokens: u64,
390    pub cache_creation_input_tokens: u64,
391    pub cache_read_input_tokens: u64,
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397
398    #[test]
399    fn reducer_maps_reasoning_text_tool_and_usage() {
400        let upstream = concat!(
401            "data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"think\"}}]}\n\n",
402            "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\n",
403            "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"function\":{\"name\":\"search\",\"arguments\":\"{\\\"q\\\"\"}}]}}}]}\n\n",
404            "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\":\\\"rust\\\"}\"}}]}}]}\n\n",
405            "data: {\"choices\":[{\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":3,\"prompt_tokens_details\":{\"cached_tokens\":4}}}\n\n",
406            "data: [DONE]\n\n"
407        );
408        let events = reduce_upstream_bytes(upstream.as_bytes()).unwrap();
409        assert!(
410            events
411                .iter()
412                .any(|e| matches!(e, ReducerEvent::ThinkingDelta { text, .. } if text == "think"))
413        );
414        assert!(events.iter().any(|e| matches!(
415            e,
416            ReducerEvent::Finish {
417                stop_reason: StopReason::ToolUse,
418                ..
419            }
420        )));
421    }
422
423    #[test]
424    fn reducer_handles_simple_text() {
425        let upstream = concat!(
426            "data: {\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\n",
427            "data: {\"choices\":[{\"delta\":{\"content\":\" world\"}}]}\n\n",
428            "data: {\"choices\":[{\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":2}}\n\n",
429            "data: [DONE]\n\n"
430        );
431        let events = reduce_upstream_bytes(upstream.as_bytes()).unwrap();
432        assert!(
433            events
434                .iter()
435                .any(|e| matches!(e, ReducerEvent::TextStart { .. }))
436        );
437        let texts: Vec<&str> = events
438            .iter()
439            .filter_map(|e| match e {
440                ReducerEvent::TextDelta { text, .. } => Some(text.as_str()),
441                _ => None,
442            })
443            .collect();
444        assert_eq!(texts, vec!["hello", " world"]);
445        assert!(events.iter().any(|e| matches!(
446            e,
447            ReducerEvent::Finish {
448                stop_reason: StopReason::EndTurn,
449                ..
450            }
451        )));
452    }
453
454    #[test]
455    fn reducer_handles_max_tokens() {
456        let upstream = concat!(
457            "data: {\"choices\":[{\"delta\":{\"content\":\"partial\"}}]}\n\n",
458            "data: {\"choices\":[{\"finish_reason\":\"length\"}]}\n\n",
459            "data: [DONE]\n\n"
460        );
461        let events = reduce_upstream_bytes(upstream.as_bytes()).unwrap();
462        assert!(events.iter().any(|e| matches!(
463            e,
464            ReducerEvent::Finish {
465                stop_reason: StopReason::MaxTokens,
466                ..
467            }
468        )));
469    }
470
471    #[test]
472    fn reducer_ignores_invalid_json() {
473        let upstream = concat!(
474            "data: {\"choices\":[{\"delta\":{\"content\":\"valid\"}}]}\n\n",
475            "data: not json\n\n",
476            "data: {\"choices\":[{\"finish_reason\":\"stop\"}]}\n\n",
477        );
478        let events = reduce_upstream_bytes(upstream.as_bytes()).unwrap();
479        let texts: Vec<&str> = events
480            .iter()
481            .filter_map(|e| match e {
482                ReducerEvent::TextDelta { text, .. } => Some(text.as_str()),
483                _ => None,
484            })
485            .collect();
486        assert_eq!(texts, vec!["valid"]);
487    }
488
489    #[test]
490    fn reducer_returns_error_on_upstream_error() {
491        let upstream = "data: {\"error\":{\"message\":\"rate limit exceeded\"}}\n\n";
492        let result = reduce_upstream_bytes(upstream.as_bytes());
493        assert!(result.is_err());
494        assert_eq!(result.unwrap_err().kind, UpstreamErrorKind::Failed);
495    }
496
497    #[test]
498    fn map_usage_subtracts_cached() {
499        let usage = KimiUsage {
500            prompt_tokens: Some(100),
501            completion_tokens: Some(50),
502            cached_tokens: Some(20),
503            ..Default::default()
504        };
505        let mapped = map_usage_to_anthropic(&Some(usage));
506        assert_eq!(mapped.input_tokens, 80);
507        assert_eq!(mapped.output_tokens, 50);
508        assert_eq!(mapped.cache_read_input_tokens, 20);
509    }
510}