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