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 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 if let Some(ref content) = delta.content
241 && !content.is_empty()
242 {
243 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 if let Some(ref tool_calls) = delta.tool_calls
262 && !tool_calls.is_empty()
263 {
264 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 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 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 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 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}