1use async_openai::config::OpenAIConfig;
4use async_openai::types::chat::{
5 ChatCompletionMessageToolCalls, ChatCompletionRequestMessage,
6 ChatCompletionRequestToolMessage, ChatCompletionResponseStream,
7 ChatCompletionTools, CreateChatCompletionRequest, CreateChatCompletionResponse,
8};
9
10use crate::config::{resolve_profile, ResolvedModel, RobitConfig};
11use crate::error::LlmError;
12
13fn validate_and_filter_messages(mut messages: Vec<ChatCompletionRequestMessage>) -> Vec<ChatCompletionRequestMessage> {
16 let original_len = messages.len();
17 messages.retain(|msg| {
18 match msg {
19 ChatCompletionRequestMessage::Assistant(assistant_msg) => {
20 let has_content = assistant_msg.content.is_some();
22 let has_tool_calls = assistant_msg.tool_calls.is_some();
23 if !has_content && !has_tool_calls {
24 tracing::warn!("Filtering out invalid assistant message (has neither content nor tool_calls)");
25 false
26 } else {
27 true
28 }
29 }
30 _ => true
31 }
32 });
33 let filtered_len = messages.len();
34 if filtered_len < original_len {
35 tracing::info!("Filtered {} invalid messages from history", original_len - filtered_len);
36 }
37 messages
38}
39
40fn repair_tool_pairing(
54 messages: Vec<ChatCompletionRequestMessage>,
55) -> Vec<ChatCompletionRequestMessage> {
56 use std::collections::HashSet;
57
58 let mut open_ids: HashSet<String> = HashSet::new();
60 let mut keep: Vec<bool> = Vec::with_capacity(messages.len());
61 let mut dropped = 0usize;
62 for msg in &messages {
63 match msg {
64 ChatCompletionRequestMessage::Assistant(a) => {
65 if let Some(tool_calls) = &a.tool_calls {
66 for tc in tool_calls {
67 if let ChatCompletionMessageToolCalls::Function(f) = tc {
68 open_ids.insert(f.id.clone());
69 }
70 }
71 }
72 keep.push(true);
73 }
74 ChatCompletionRequestMessage::Tool(t) => {
75 if open_ids.remove(&t.tool_call_id) {
76 keep.push(true);
77 } else {
78 tracing::trace!(
79 "repair_tool_pairing: dropping orphaned tool message \
80 (tool_call_id='{}' not declared by any preceding assistant tool_calls)",
81 t.tool_call_id
82 );
83 keep.push(false);
84 dropped += 1;
85 }
86 }
87 _ => keep.push(true),
88 }
89 }
90 let mut missing = open_ids;
92
93 if dropped == 0 && missing.is_empty() {
94 return messages; }
96 if dropped > 0 {
97 tracing::warn!(
101 "repair_tool_pairing: dropped {} orphaned tool message(s) not declared by any assistant tool_calls",
102 dropped
103 );
104 }
105
106 let mut synthesized = 0usize;
108 let mut result: Vec<ChatCompletionRequestMessage> = Vec::with_capacity(messages.len());
109 for (msg, keep) in messages.into_iter().zip(keep.into_iter()) {
110 if !keep {
111 continue;
112 }
113 let missing_here: Vec<String> = match &msg {
115 ChatCompletionRequestMessage::Assistant(a) => a
116 .tool_calls
117 .as_ref()
118 .map(|tool_calls| {
119 tool_calls
120 .iter()
121 .filter_map(|tc| {
122 if let ChatCompletionMessageToolCalls::Function(f) = tc {
123 if missing.remove(&f.id) {
125 Some(f.id.clone())
126 } else {
127 None
128 }
129 } else {
130 None
131 }
132 })
133 .collect()
134 })
135 .unwrap_or_default(),
136 _ => Vec::new(),
137 };
138 result.push(msg);
139 for id in missing_here {
140 tracing::trace!(
141 "repair_tool_pairing: synthesizing missing tool response for tool_call_id='{}'",
142 id
143 );
144 synthesized += 1;
145 result.push(ChatCompletionRequestMessage::Tool(
146 ChatCompletionRequestToolMessage {
147 content: "[Tool result unavailable — session history was restored without this result]"
148 .to_string()
149 .into(),
150 tool_call_id: id,
151 }
152 .into(),
153 ));
154 }
155 }
156 if synthesized > 0 {
157 tracing::warn!(
158 "repair_tool_pairing: synthesized {} missing tool response(s) for declared tool_calls",
159 synthesized
160 );
161 }
162 result
163}
164
165fn repair_interleaved_tool_responses(
180 messages: Vec<ChatCompletionRequestMessage>,
181) -> Vec<ChatCompletionRequestMessage> {
182 use std::collections::HashSet;
183
184 {
187 let mut pending: HashSet<&String> = HashSet::new();
188 let mut interleaved = false;
189 'scan: for msg in &messages {
190 match msg {
191 ChatCompletionRequestMessage::Assistant(a) => {
192 if let Some(tool_calls) = &a.tool_calls {
193 pending = tool_calls
194 .iter()
195 .filter_map(|tc| {
196 if let ChatCompletionMessageToolCalls::Function(f) = tc {
197 Some(&f.id)
198 } else {
199 None
200 }
201 })
202 .collect();
203 }
204 }
205 ChatCompletionRequestMessage::Tool(t) => {
206 pending.remove(&t.tool_call_id);
207 }
208 _ => {
209 if !pending.is_empty() {
210 interleaved = true;
211 break 'scan;
212 }
213 }
214 }
215 }
216 if !interleaved {
217 return messages;
218 }
219 }
220
221 tracing::warn!(
222 "repair_interleaved_tool_responses: moving non-tool message(s) out of a \
223 tool_calls → tool-responses window (providers reject interleaved messages \
224 with a 400 error)"
225 );
226
227 let mut result: Vec<ChatCompletionRequestMessage> = Vec::with_capacity(messages.len());
230 let mut deferred: Vec<ChatCompletionRequestMessage> = Vec::new();
231 let mut pending: HashSet<String> = HashSet::new();
232
233 for msg in messages {
234 match &msg {
235 ChatCompletionRequestMessage::Assistant(a) => {
236 if let Some(tool_calls) = &a.tool_calls {
237 if !deferred.is_empty() {
240 result.append(&mut deferred);
241 }
242 pending = tool_calls
243 .iter()
244 .filter_map(|tc| {
245 if let ChatCompletionMessageToolCalls::Function(f) = tc {
246 Some(f.id.clone())
247 } else {
248 None
249 }
250 })
251 .collect();
252 result.push(msg);
253 } else if pending.is_empty() {
254 result.push(msg);
255 } else {
256 deferred.push(msg);
257 }
258 }
259 ChatCompletionRequestMessage::Tool(t) => {
260 let responded = pending.remove(&t.tool_call_id);
261 result.push(msg);
262 if responded && pending.is_empty() && !deferred.is_empty() {
263 result.append(&mut deferred);
264 }
265 }
266 _ => {
267 if pending.is_empty() {
268 result.push(msg);
269 } else {
270 deferred.push(msg);
271 }
272 }
273 }
274 }
275 result.append(&mut deferred);
280 result
281}
282
283pub struct LlmClient {
284 client: async_openai::Client<OpenAIConfig>,
285 model: String,
286 resolved: ResolvedModel,
287}
288
289impl LlmClient {
290 pub fn from_config(
294 config: &RobitConfig,
295 profile_name: Option<&str>,
296 ) -> Result<Self, LlmError> {
297 let resolved = resolve_profile(config, profile_name)?;
298
299 let oc = OpenAIConfig::new()
300 .with_api_base(&resolved.base_url)
301 .with_api_key(&resolved.api_key);
302
303 let client = async_openai::Client::with_config(oc);
304
305 Ok(Self {
306 client,
307 model: resolved.model_id.clone(),
308 resolved,
309 })
310 }
311
312 pub async fn chat_stream(
314 &self,
315 messages: Vec<ChatCompletionRequestMessage>,
316 tools: Option<Vec<ChatCompletionTools>>,
317 ) -> Result<ChatCompletionResponseStream, LlmError> {
318 let messages = validate_and_filter_messages(messages);
323 let messages = repair_interleaved_tool_responses(messages);
324 let messages = repair_tool_pairing(messages);
325 let msg_count = messages.len();
326
327 tracing::trace!("Creating chat stream for model={}, messages={}", self.model, msg_count);
328
329 let request = CreateChatCompletionRequest {
330 model: self.model.clone(),
331 messages,
332 tools,
333 stream: Some(true),
334 stream_options: Some(async_openai::types::chat::ChatCompletionStreamOptions {
338 include_usage: Some(true),
339 include_obfuscation: None,
340 }),
341 max_completion_tokens: self.resolved.max_tokens,
342 temperature: self.resolved.temperature,
343 ..Default::default()
344 };
345 let stream = self.client.chat().create_stream(request).await;
346 if let Err(e) = &stream {
347 tracing::error!("Chat stream creation failed: {:?}", e);
348 }
349 let stream = stream.map_err(LlmError::from_openai_error)?;
353 Ok(stream)
354 }
355
356 pub async fn chat(
358 &self,
359 messages: Vec<ChatCompletionRequestMessage>,
360 tools: Option<Vec<ChatCompletionTools>>,
361 ) -> Result<CreateChatCompletionResponse, LlmError> {
362 let messages = validate_and_filter_messages(messages);
365 let messages = repair_interleaved_tool_responses(messages);
366 let messages = repair_tool_pairing(messages);
367
368 let request = CreateChatCompletionRequest {
369 model: self.model.clone(),
370 messages,
371 tools,
372 max_completion_tokens: self.resolved.max_tokens,
373 temperature: self.resolved.temperature,
374 ..Default::default()
375 };
376
377 let response = self
378 .client
379 .chat()
380 .create(request)
381 .await
382 .map_err(LlmError::from_openai_error)?;
383 Ok(response)
384 }
385
386 pub fn model(&self) -> &str {
388 &self.model
389 }
390
391 pub fn profile(&self) -> &str {
393 &self.resolved.profile_name
394 }
395
396 pub fn resolved(&self) -> &ResolvedModel {
398 &self.resolved
399 }
400
401 pub fn supports_images(&self) -> bool {
403 self.resolved.supports_images
404 }
405
406 pub fn supports_tools(&self) -> bool {
408 self.resolved.supports_tools
409 }
410}
411
412#[cfg(test)]
417mod tests {
418 use super::*;
419 use async_openai::types::chat::{
420 ChatCompletionMessageToolCall, ChatCompletionRequestAssistantMessage,
421 ChatCompletionRequestUserMessage, FunctionCall,
422 };
423
424 fn user_msg(text: &str) -> ChatCompletionRequestMessage {
425 ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage {
426 content: text.to_string().into(),
427 name: None,
428 })
429 }
430
431 fn assistant_text_msg(text: &str) -> ChatCompletionRequestMessage {
432 ChatCompletionRequestMessage::Assistant(ChatCompletionRequestAssistantMessage {
433 content: Some(text.to_string().into()),
434 name: None,
435 tool_calls: None,
436 refusal: None,
437 audio: None,
438 #[allow(deprecated)]
439 function_call: None,
440 })
441 }
442
443 fn assistant_tool_call_msg(id: &str, name: &str) -> ChatCompletionRequestMessage {
444 ChatCompletionRequestMessage::Assistant(ChatCompletionRequestAssistantMessage {
445 content: None,
446 name: None,
447 tool_calls: Some(vec![ChatCompletionMessageToolCalls::Function(
448 ChatCompletionMessageToolCall {
449 id: id.to_string(),
450 function: FunctionCall {
451 name: name.to_string(),
452 arguments: "{}".to_string(),
453 },
454 },
455 )]),
456 refusal: None,
457 audio: None,
458 #[allow(deprecated)]
459 function_call: None,
460 })
461 }
462
463 fn tool_msg(id: &str, text: &str) -> ChatCompletionRequestMessage {
464 ChatCompletionRequestMessage::Tool(ChatCompletionRequestToolMessage {
465 content: text.to_string().into(),
466 tool_call_id: id.to_string(),
467 })
468 }
469
470 #[test]
471 fn repair_drops_orphaned_tool_messages() {
472 let messages = vec![
475 user_msg("hello"),
476 assistant_text_msg("hi"),
477 user_msg("do something"),
478 tool_msg("call_1", "orphaned result"),
479 assistant_text_msg("done"),
480 ];
481 let repaired = repair_tool_pairing(messages);
482 assert_eq!(repaired.len(), 4, "orphaned tool message should be dropped");
483 assert!(
484 !repaired
485 .iter()
486 .any(|m| matches!(m, ChatCompletionRequestMessage::Tool(_))),
487 "no tool messages should remain"
488 );
489 }
490
491 #[test]
492 fn repair_synthesizes_missing_tool_responses() {
493 let messages = vec![
496 user_msg("do something"),
497 assistant_tool_call_msg("call_1", "bash"),
498 user_msg("next question"),
499 ];
500 let repaired = repair_tool_pairing(messages);
501 assert_eq!(repaired.len(), 4, "placeholder tool response should be added");
502 assert!(matches!(repaired[2], ChatCompletionRequestMessage::Tool(_)));
504 if let ChatCompletionRequestMessage::Tool(t) = &repaired[2] {
505 assert_eq!(t.tool_call_id, "call_1");
506 }
507 }
508
509 #[test]
510 fn repair_keeps_valid_pairing_untouched() {
511 let messages = vec![
512 user_msg("do something"),
513 assistant_tool_call_msg("call_1", "bash"),
514 tool_msg("call_1", "ok"),
515 assistant_text_msg("done"),
516 ];
517 let repaired = repair_tool_pairing(messages.clone());
518 assert_eq!(repaired.len(), messages.len(), "valid history must not change");
519 }
520
521 #[test]
522 fn repair_handles_mixed_valid_and_orphaned() {
523 let messages = vec![
524 user_msg("a"),
525 assistant_tool_call_msg("call_1", "read"),
526 tool_msg("call_1", "result 1"), tool_msg("call_ghost", "ghost result"), assistant_text_msg("done"),
529 ];
530 let repaired = repair_tool_pairing(messages);
531 assert_eq!(repaired.len(), 4);
532 let tool_ids: Vec<&str> = repaired
533 .iter()
534 .filter_map(|m| {
535 if let ChatCompletionRequestMessage::Tool(t) = m {
536 Some(t.tool_call_id.as_str())
537 } else {
538 None
539 }
540 })
541 .collect();
542 assert_eq!(tool_ids, vec!["call_1"]);
543 }
544
545 fn assistant_multi_tool_call_msg(
546 calls: &[(&str, &str)],
547 ) -> ChatCompletionRequestMessage {
548 ChatCompletionRequestMessage::Assistant(ChatCompletionRequestAssistantMessage {
549 content: None,
550 name: None,
551 tool_calls: Some(
552 calls
553 .iter()
554 .map(|(id, name)| {
555 ChatCompletionMessageToolCalls::Function(ChatCompletionMessageToolCall {
556 id: id.to_string(),
557 function: FunctionCall {
558 name: name.to_string(),
559 arguments: "{}".to_string(),
560 },
561 })
562 })
563 .collect(),
564 ),
565 refusal: None,
566 audio: None,
567 #[allow(deprecated)]
568 function_call: None,
569 })
570 }
571
572 fn image_user_msg(label: &str) -> ChatCompletionRequestMessage {
573 user_msg(&format!("[工具返回的图片] {}", label))
574 }
575
576 #[test]
577 fn interleave_repair_moves_user_messages_after_tool_batch() {
578 let messages = vec![
583 user_msg("generate images"),
584 assistant_multi_tool_call_msg(&[
585 ("call_0", "read"),
586 ("call_1", "read"),
587 ("call_2", "read"),
588 ]),
589 tool_msg("call_0", "Image file: a.png"),
590 image_user_msg("a.png"),
591 tool_msg("call_1", "Image file: b.png"),
592 image_user_msg("b.png"),
593 tool_msg("call_2", "Image file: c.png"),
594 image_user_msg("c.png"),
595 ];
596 let repaired = repair_interleaved_tool_responses(messages);
597 assert_eq!(repaired.len(), 8, "no message may be dropped");
598
599 let role_kinds: Vec<&str> = repaired
600 .iter()
601 .map(|m| match m {
602 ChatCompletionRequestMessage::User(_) => "user",
603 ChatCompletionRequestMessage::Assistant(a) => {
604 if a.tool_calls.is_some() {
605 "assistant+tool_calls"
606 } else {
607 "assistant"
608 }
609 }
610 ChatCompletionRequestMessage::Tool(_) => "tool",
611 _ => "other",
612 })
613 .collect();
614 assert_eq!(
617 role_kinds,
618 vec![
619 "user",
620 "assistant+tool_calls",
621 "tool",
622 "tool",
623 "tool",
624 "user",
625 "user",
626 "user",
627 ]
628 );
629 let user_texts: Vec<String> = repaired
631 .iter()
632 .filter_map(|m| {
633 if let ChatCompletionRequestMessage::User(u) = m {
634 if let async_openai::types::chat::ChatCompletionRequestUserMessageContent::Text(t) = &u.content {
635 Some(t.clone())
636 } else {
637 None
638 }
639 } else {
640 None
641 }
642 })
643 .collect();
644 assert_eq!(
645 user_texts.last().map(|t| t.contains("c.png")),
646 Some(true),
647 "deferred messages keep their original order (c.png last)"
648 );
649 }
650
651 #[test]
652 fn interleave_repair_keeps_valid_history_untouched() {
653 let messages = vec![
654 user_msg("look at this"),
655 assistant_tool_call_msg("call_1", "read"),
656 tool_msg("call_1", "Image file: a.png"),
657 image_user_msg("a.png"),
658 assistant_text_msg("looks great"),
659 ];
660 let repaired = repair_interleaved_tool_responses(messages.clone());
661 assert_eq!(
662 repaired.len(),
663 messages.len(),
664 "valid history must not change length"
665 );
666 let summarize = |m: &ChatCompletionRequestMessage| match m {
668 ChatCompletionRequestMessage::User(u) => format!("user:{:?}", u.content),
669 ChatCompletionRequestMessage::Assistant(a) => format!(
670 "assistant:{:?}:{:?}",
671 a.content, a.tool_calls.as_ref().map(|tcs| tcs.len())
672 ),
673 ChatCompletionRequestMessage::Tool(t) => {
674 format!("tool:{}:{:?}", t.tool_call_id, t.content)
675 }
676 _ => "other".to_string(),
677 };
678 let before: Vec<String> = messages.iter().map(summarize).collect();
679 let after: Vec<String> = repaired.iter().map(summarize).collect();
680 assert_eq!(before, after, "valid history must not be reordered");
681 }
682
683 #[test]
684 fn interleave_repair_truncated_batch_defers_to_end() {
685 let messages = vec![
688 user_msg("do something"),
689 assistant_multi_tool_call_msg(&[("call_0", "read"), ("call_1", "read")]),
690 tool_msg("call_0", "result 0"),
691 image_user_msg("a.png"),
692 ];
693 let repaired = repair_interleaved_tool_responses(messages);
694 assert_eq!(repaired.len(), 4);
695 assert!(matches!(repaired[1], ChatCompletionRequestMessage::Assistant(_)));
696 assert!(matches!(repaired[2], ChatCompletionRequestMessage::Tool(_)));
697 assert!(matches!(repaired[3], ChatCompletionRequestMessage::User(_)));
698 }
699
700 #[test]
701 fn interleave_repair_flushes_deferred_before_next_assistant_batch() {
702 let messages = vec![
705 user_msg("start"),
706 assistant_multi_tool_call_msg(&[("call_0", "read"), ("call_1", "read")]),
707 tool_msg("call_0", "result 0"),
708 image_user_msg("a.png"),
709 assistant_tool_call_msg("call_2", "bash"),
710 tool_msg("call_2", "ok"),
711 ];
712 let repaired = repair_interleaved_tool_responses(messages);
713 let role_kinds: Vec<&str> = repaired
714 .iter()
715 .map(|m| match m {
716 ChatCompletionRequestMessage::User(_) => "user",
717 ChatCompletionRequestMessage::Assistant(_) => "assistant",
718 ChatCompletionRequestMessage::Tool(_) => "tool",
719 _ => "other",
720 })
721 .collect();
722 assert_eq!(
723 role_kinds,
724 vec!["user", "assistant", "tool", "user", "assistant", "tool"]
725 );
726 }
727}