1use crate::config::core::{PromptCachingConfig, ProviderPromptCachingConfig};
2use crate::llm::error_display;
3use crate::llm::provider::{
4 ContentPart, FinishReason, LLMError, LLMRequest, LLMStream, LLMStreamEvent, Message,
5 MessageContent, MessageRole, ToolCall, ToolDefinition,
6};
7use crate::llm::types as llm_types;
8use crate::llm::utils::extract_reasoning_content;
9use serde_json::{Value, json};
10
11use super::openai::tool_serialization::sanitize_openai_function_parameters;
12
13pub fn collect_history_system_directives(request: &LLMRequest) -> Vec<String> {
17 request
18 .messages
19 .iter()
20 .filter(|message| message.role == MessageRole::System)
21 .map(|message| message.content.as_text().trim().to_string())
22 .filter(|text| !text.is_empty())
23 .collect()
24}
25
26pub fn merge_system_prompt_with_history_directives(
30 base_prompt: Option<&str>,
31 directives: &[String],
32 section_header: &str,
33) -> Option<String> {
34 let mut system_prompt = base_prompt
35 .map(str::trim)
36 .filter(|prompt| !prompt.is_empty())
37 .map(str::to_owned)
38 .unwrap_or_default();
39
40 if directives.is_empty() {
41 return (!system_prompt.is_empty()).then_some(system_prompt);
42 }
43
44 if !system_prompt.is_empty() {
45 system_prompt.push('\n');
46 }
47 system_prompt.push_str(section_header);
48 system_prompt.push('\n');
49 for directive in directives {
50 system_prompt.push_str("- ");
51 system_prompt.push_str(directive);
52 system_prompt.push('\n');
53 }
54
55 Some(system_prompt)
56}
57
58#[inline]
65pub fn serialize_tools_openai_format(tools: &[ToolDefinition]) -> Option<Vec<Value>> {
66 if tools.is_empty() {
67 return None;
68 }
69 Some(
70 tools
71 .iter()
72 .filter_map(|tool| {
73 if tool.tool_type == "web_search" {
74 let mut payload = serde_json::Map::new();
75 payload.insert("type".to_owned(), Value::String("web_search".to_owned()));
76 payload.insert(
77 "web_search".to_owned(),
78 tool.web_search
79 .clone()
80 .unwrap_or_else(|| json!({"enable": true})),
81 );
82 return Some(Value::Object(payload));
83 }
84
85 tool.function.as_ref().map(|func| {
88 let parameters =
89 sanitize_openai_function_parameters(func.parameters.clone(), true);
90 serde_json::json!({
91 "type": "function",
92 "function": {
93 "name": func.name,
94 "description": func.description,
95 "parameters": parameters
96 }
97 })
98 })
99 })
100 .collect(),
101 )
102}
103
104pub fn serialize_message_content_openai(content: &MessageContent) -> Value {
107 match content {
108 MessageContent::Text(text) => Value::String(text.clone()),
109 MessageContent::Parts(parts) => {
110 if parts.is_empty() {
111 return Value::String(String::new());
112 }
113
114 let mut has_non_text = false;
115 let mut serialized_parts = Vec::with_capacity(parts.len());
116 let mut text_only = String::new();
117
118 for part in parts {
119 match part {
120 ContentPart::Text { text } => {
121 text_only.push_str(text);
122 serialized_parts.push(json!({
123 "type": "text",
124 "text": text
125 }));
126 }
127 ContentPart::Image {
128 data, mime_type, ..
129 } => {
130 has_non_text = true;
131 let url = {
132 let mut s = String::with_capacity(13 + mime_type.len() + data.len());
133 s.push_str("data:");
134 s.push_str(mime_type);
135 s.push_str(";base64,");
136 s.push_str(data);
137 s
138 };
139 serialized_parts.push(json!({
140 "type": "image_url",
141 "image_url": {
142 "url": url
143 }
144 }));
145 }
146 ContentPart::File {
147 filename,
148 file_id,
149 file_data,
150 file_url,
151 ..
152 } => {
153 if file_id.is_some() || file_data.is_some() {
154 has_non_text = true;
155 let mut file_payload = serde_json::Map::new();
156 if let Some(id) = file_id {
157 file_payload
158 .insert("file_id".to_owned(), Value::String(id.clone()));
159 }
160 if let Some(name) = filename {
161 file_payload
162 .insert("filename".to_owned(), Value::String(name.clone()));
163 }
164 if let Some(data) = file_data {
165 file_payload
166 .insert("file_data".to_owned(), Value::String(data.clone()));
167 }
168 serialized_parts.push(json!({
169 "type": "file",
170 "file": Value::Object(file_payload)
171 }));
172 } else if let Some(url) = file_url {
173 text_only.push_str(url);
175 serialized_parts.push(json!({
176 "type": "text",
177 "text": url
178 }));
179 }
180 }
181 }
182 }
183
184 if has_non_text {
185 Value::Array(serialized_parts)
186 } else {
187 Value::String(text_only)
188 }
189 }
190 }
191}
192
193#[inline]
196pub fn serialize_message_content_openai_for_role(
197 role: &MessageRole,
198 content: &MessageContent,
199) -> Value {
200 let serialized = serialize_message_content_openai(content);
201 if role == &MessageRole::Tool && !serialized.is_string() {
202 Value::String(content.as_text().into_owned())
203 } else {
204 serialized
205 }
206}
207
208pub fn serialize_message_content_openai_for_model(message: &Message, model: &str) -> Value {
211 if let Some(interleaved_content) = assistant_interleaved_history_text(message, model) {
212 Value::String(interleaved_content)
213 } else {
214 serialize_message_content_openai_for_role(&message.role, &message.content)
215 }
216}
217
218#[inline]
221pub fn is_minimax_m2_model(model: &str) -> bool {
222 model.to_ascii_lowercase().contains("minimax-m2")
223}
224
225#[inline]
226fn is_glm_interleaved_thinking_model(model: &str) -> bool {
227 let lower = model.to_ascii_lowercase();
228 lower.contains("glm-5") || lower.contains("glm45") || lower.contains("glm-4.5")
229}
230
231#[inline]
234pub fn is_interleaved_thinking_model(model: &str) -> bool {
235 is_minimax_m2_model(model) || is_glm_interleaved_thinking_model(model)
236}
237
238#[inline]
239fn text_contains_interleaved_reasoning_markup(text: &str) -> bool {
240 let lower = text.to_ascii_lowercase();
241 lower.contains("<think")
242 || lower.contains("<thinking")
243 || lower.contains("<reasoning")
244 || lower.contains("<analysis")
245 || lower.contains("<thought")
246}
247
248fn message_content_is_text_only(content: &MessageContent) -> bool {
249 match content {
250 MessageContent::Text(_) => true,
251 MessageContent::Parts(parts) => parts
252 .iter()
253 .all(|part| matches!(part, ContentPart::Text { .. })),
254 }
255}
256
257fn preserved_interleaved_content_from_details(details: &[Value]) -> Option<String> {
258 details.iter().find_map(|detail| match detail {
259 Value::String(text)
260 if !text.trim().is_empty() && text_contains_interleaved_reasoning_markup(text) =>
261 {
262 Some(text.clone())
263 }
264 _ => None,
265 })
266}
267
268pub fn assistant_interleaved_history_text(message: &Message, model: &str) -> Option<String> {
271 if message.role != MessageRole::Assistant
272 || !is_interleaved_thinking_model(model)
273 || !message_content_is_text_only(&message.content)
274 {
275 return None;
276 }
277
278 if let Some(details) = message.reasoning_details.as_deref()
279 && let Some(raw_content) = preserved_interleaved_content_from_details(details)
280 {
281 return Some(raw_content);
282 }
283
284 let content = message.content.as_text();
285 if text_contains_interleaved_reasoning_markup(content.as_ref()) {
286 return Some(content.into_owned());
287 }
288
289 let reasoning = message
290 .reasoning
291 .as_deref()
292 .map(str::trim)
293 .filter(|value| !value.is_empty())
294 .map(str::to_owned)
295 .or_else(|| {
296 message
297 .reasoning_details
298 .as_deref()
299 .and_then(extract_reasoning_text_from_detail_values)
300 })?;
301
302 let mut combined = String::with_capacity(reasoning.len() + content.len() + 16);
303 combined.push_str("<think>");
304 combined.push_str(reasoning.trim());
305 combined.push_str("</think>");
306 combined.push_str(content.as_ref());
307 Some(combined)
308}
309
310pub fn preserve_interleaved_content_in_reasoning_details(
313 reasoning_details: &mut Option<Vec<String>>,
314 raw_content: &str,
315) {
316 if raw_content.trim().is_empty() || !text_contains_interleaved_reasoning_markup(raw_content) {
317 return;
318 }
319
320 match reasoning_details {
321 Some(existing) => {
322 if !existing.iter().any(|detail| detail == raw_content) {
323 existing.push(raw_content.to_string());
324 }
325 }
326 None => {
327 *reasoning_details = Some(vec![raw_content.to_string()]);
328 }
329 }
330}
331
332pub fn normalize_reasoning_detail_object(detail: &Value) -> Option<Value> {
335 match detail {
336 Value::Object(_) => Some(detail.clone()),
337 Value::String(text) => {
338 let trimmed = text.trim();
339 if trimmed.is_empty() {
340 return None;
341 }
342
343 if (trimmed.starts_with('{') || trimmed.starts_with('['))
344 && let Ok(parsed) = serde_json::from_str::<Value>(trimmed)
345 && parsed.is_object()
346 {
347 return Some(parsed);
348 }
349
350 None
351 }
352 _ => None,
353 }
354}
355
356#[inline]
357pub fn normalize_reasoning_detail_objects(details: &[Value]) -> Vec<Value> {
358 details
359 .iter()
360 .filter_map(normalize_reasoning_detail_object)
361 .collect()
362}
363
364#[inline]
365pub fn append_normalized_reasoning_detail_items(input: &mut Vec<Value>, details: &[Value]) {
366 for item in details {
367 if let Some(normalized) = normalize_reasoning_detail_object(item) {
368 input.push(normalized);
369 }
370 }
371}
372
373pub fn resolve_model(model: Option<String>, default_model: &str) -> String {
374 model
375 .filter(|value| !value.trim().is_empty())
376 .unwrap_or_else(|| default_model.to_owned())
377}
378
379pub fn ensure_model(request: &mut LLMRequest, default_model: &str) -> String {
382 if request.model.trim().is_empty() {
383 request.model = default_model.to_owned();
384 }
385 request.model.clone()
386}
387
388pub async fn parse_json_response(
391 response: reqwest::Response,
392 provider_name: &str,
393) -> Result<Value, LLMError> {
394 response.json().await.map_err(|e| LLMError::Provider {
395 message: error_display::format_llm_error(
396 provider_name,
397 &format!("failed to parse response: {}", e),
398 ),
399 metadata: None,
400 })
401}
402
403pub fn validate_supported_models(
407 request: &LLMRequest,
408 provider_name: &str,
409 provider_key: &str,
410 supported_models: &[&str],
411) -> Result<(), LLMError> {
412 let models: Vec<String> = supported_models.iter().map(|m| m.to_string()).collect();
413 validate_request_common(request, provider_name, provider_key, Some(&models))
414}
415
416pub fn spawn_openai_compatible_stream(
423 response: reqwest::Response,
424 provider_name: &'static str,
425 model: String,
426 reasoning_field: Option<&'static str>,
427 delta_order: crate::llm::providers::shared::OpenAiDeltaOrder,
428) -> LLMStream {
429 use async_stream::try_stream;
430
431 let bytes_stream = response.bytes_stream();
432 let (event_tx, event_rx) =
433 tokio::sync::mpsc::unbounded_channel::<Result<LLMStreamEvent, LLMError>>();
434 let tx = event_tx.clone();
435
436 tokio::spawn(async move {
437 let aggregator_model = model.clone();
438 let mut aggregator = crate::llm::providers::shared::StreamAggregator::new(aggregator_model);
439
440 let result = crate::llm::providers::shared::process_openai_stream(
441 bytes_stream,
442 provider_name,
443 model,
444 |value| {
445 crate::llm::providers::shared::handle_openai_compatible_chunk(
446 &value,
447 &mut aggregator,
448 &tx,
449 reasoning_field,
450 delta_order,
451 );
452 Ok(())
453 },
454 )
455 .await;
456
457 match result {
458 Ok(_) => {
459 let response = aggregator.finalize();
460 let _ = tx.send(Ok(LLMStreamEvent::Completed {
461 response: Box::new(response),
462 }));
463 }
464 Err(err) => {
465 let _ = tx.send(Err(err));
466 }
467 }
468 });
469
470 let stream = try_stream! {
471 let mut receiver = event_rx;
472 while let Some(event) = receiver.recv().await {
473 yield event?;
474 }
475 };
476
477 Box::pin(stream)
478}
479
480macro_rules! impl_llm_client {
483 ($provider:ty) => {
484 #[async_trait::async_trait]
485 impl crate::llm::client::LLMClient for $provider {
486 async fn generate(
487 &mut self,
488 prompt: &str,
489 ) -> Result<crate::llm::provider::LLMResponse, crate::llm::provider::LLMError> {
490 let request = super::common::make_default_request(prompt, &self.model);
491 Ok(
492 <$provider as crate::llm::provider::LLMProvider>::generate(self, request)
493 .await?,
494 )
495 }
496
497 fn model_id(&self) -> &str {
498 &self.model
499 }
500 }
501 };
502}
503
504pub(crate) use impl_llm_client;
505
506#[inline]
509pub fn make_default_request(prompt: &str, model: &str) -> LLMRequest {
510 LLMRequest {
511 messages: vec![Message::user(prompt.to_owned())],
512 model: model.to_owned(),
513 ..Default::default()
514 }
515}
516
517#[inline]
520pub fn parse_client_prompt_common<F>(prompt: &str, model: &str, parse_json: F) -> LLMRequest
521where
522 F: FnOnce(&Value) -> Option<LLMRequest>,
523{
524 let trimmed = prompt.trim_start();
525 if trimmed.starts_with('{')
526 && let Ok(value) = serde_json::from_str::<Value>(trimmed)
527 && let Some(request) = parse_json(&value)
528 {
529 return request;
530 }
531 make_default_request(prompt, model)
532}
533
534#[inline]
537pub fn convert_usage_to_llm_types(usage: crate::llm::provider::Usage) -> llm_types::Usage {
538 usage
539}
540
541pub fn override_base_url(
542 default_base_url: &str,
543 base_url: Option<String>,
544 env_var_name: Option<&str>,
545) -> String {
546 if let Some(url) = base_url {
547 let trimmed = url.trim();
548 if !trimmed.is_empty() {
549 return trimmed.to_string();
550 }
551 }
552
553 if let Some(var_name) = env_var_name
554 && let Ok(value) = std::env::var(var_name)
555 {
556 let trimmed = value.trim();
557 if !trimmed.is_empty() {
558 return trimmed.to_string();
559 }
560 }
561
562 default_base_url.to_string()
563}
564
565pub fn get_http_client_for_timeouts(
567 connect_timeout: std::time::Duration,
568 read_timeout: std::time::Duration,
569) -> reqwest::Client {
570 reqwest::Client::builder()
571 .connect_timeout(connect_timeout)
572 .timeout(read_timeout)
573 .build()
574 .unwrap_or_else(|_| reqwest::Client::new())
575}
576
577pub fn strip_generation_controls_for_token_count(payload: &mut Value) {
580 let Some(root) = payload.as_object_mut() else {
581 return;
582 };
583
584 for key in [
585 "stream",
586 "temperature",
587 "top_p",
588 "frequency_penalty",
589 "presence_penalty",
590 "stop",
591 "max_tokens",
592 "max_output_tokens",
593 "n",
594 "seed",
595 "tool_choice",
596 "parallel_tool_config",
597 "response_format",
598 "reasoning_effort",
599 "metadata",
600 "prompt_cache_key",
601 ] {
602 root.remove(key);
603 }
604}
605
606#[inline]
607fn parse_u32_value(value: &Value) -> Option<u32> {
608 value
609 .as_u64()
610 .and_then(|n| u32::try_from(n).ok())
611 .or_else(|| {
612 value
613 .as_i64()
614 .and_then(|n| u64::try_from(n).ok())
615 .and_then(|n| u32::try_from(n).ok())
616 })
617 .or_else(|| value.as_str().and_then(|s| s.parse::<u32>().ok()))
618}
619
620#[inline]
621fn value_at_path<'a>(value: &'a Value, path: &[&str]) -> Option<&'a Value> {
622 let mut cursor = value;
623 for segment in path {
624 cursor = cursor.get(*segment)?;
625 }
626 Some(cursor)
627}
628
629pub fn parse_prompt_tokens_from_count_response(value: &Value) -> Option<u32> {
631 const CANDIDATE_PATHS: &[&[&str]] = &[
632 &["prompt_tokens"],
633 &["input_tokens"],
634 &["token_count"],
635 &["usage", "prompt_tokens"],
636 &["usage", "input_tokens"],
637 &["data", "prompt_tokens"],
638 &["data", "input_tokens"],
639 &["data", "token_count"],
640 &["usage", "total_tokens"],
641 &["data", "total_tokens"],
642 &["total_tokens"],
643 ];
644
645 for path in CANDIDATE_PATHS {
646 if let Some(parsed) = value_at_path(value, path).and_then(parse_u32_value) {
647 return Some(parsed);
648 }
649 }
650 None
651}
652
653pub async fn execute_token_count_request(
656 request_builder: reqwest::RequestBuilder,
657 payload: &Value,
658 provider_name: &str,
659) -> Result<Option<Value>, LLMError> {
660 let response = request_builder.json(payload).send().await.map_err(|e| {
661 let message = error_display::format_llm_error(
662 provider_name,
663 &format!("Token-count network error: {}", e),
664 );
665 LLMError::Network {
666 message,
667 metadata: None,
668 }
669 })?;
670
671 let status = response.status();
672 if matches!(
673 status,
674 reqwest::StatusCode::BAD_REQUEST
675 | reqwest::StatusCode::UNPROCESSABLE_ENTITY
676 | reqwest::StatusCode::NOT_FOUND
677 | reqwest::StatusCode::METHOD_NOT_ALLOWED
678 | reqwest::StatusCode::NOT_IMPLEMENTED
679 ) {
680 return Ok(None);
681 }
682
683 if !status.is_success() {
684 let body = response.text().await.unwrap_or_default();
685 let message = error_display::format_llm_error(
686 provider_name,
687 &format!("Token-count request failed ({}): {}", status, body),
688 );
689 return Err(LLMError::Provider {
690 message,
691 metadata: None,
692 });
693 }
694
695 let value = response.json::<Value>().await.map_err(|e| {
696 let message = error_display::format_llm_error(
697 provider_name,
698 &format!("Failed to parse token-count response: {}", e),
699 );
700 LLMError::Provider {
701 message,
702 metadata: None,
703 }
704 })?;
705
706 Ok(Some(value))
707}
708
709pub fn extract_prompt_cache_settings_default(
710 prompt_cache: Option<PromptCachingConfig>,
711 _provider_key: &str,
712) -> (bool, bool) {
713 match prompt_cache {
714 Some(cfg) if cfg.enabled => (true, cfg.enabled),
715 _ => (false, false),
716 }
717}
718
719pub fn extract_prompt_cache_settings<T, SelectFn, EnabledFn>(
720 prompt_cache: Option<PromptCachingConfig>,
721 select_settings: SelectFn,
722 enabled: EnabledFn,
723) -> (bool, T)
724where
725 T: Clone + Default,
726 SelectFn: Fn(&ProviderPromptCachingConfig) -> &T,
727 EnabledFn: Fn(&PromptCachingConfig, &T) -> bool,
728{
729 if let Some(cfg) = prompt_cache {
730 let provider_settings = select_settings(&cfg.providers).clone();
731 let is_enabled = enabled(&cfg, &provider_settings);
732 (is_enabled, provider_settings)
733 } else {
734 (false, T::default())
735 }
736}
737
738pub fn forward_prompt_cache_with_state<PredicateFn>(
739 prompt_cache: Option<PromptCachingConfig>,
740 predicate: PredicateFn,
741 default_enabled: bool,
742) -> (bool, Option<PromptCachingConfig>)
743where
744 PredicateFn: Fn(&PromptCachingConfig) -> bool,
745{
746 match prompt_cache {
747 Some(cfg) => {
748 if predicate(&cfg) {
749 (true, Some(cfg))
750 } else {
751 (false, None)
752 }
753 }
754 None => (default_enabled, None),
755 }
756}
757
758#[inline]
761pub fn parse_tool_call_openai_format(value: &Value) -> Option<ToolCall> {
762 let id = value.get("id").and_then(|v| v.as_str())?;
763 let function = value.get("function")?;
764 let name = function.get("name").and_then(|v| v.as_str())?;
765 let arguments = function.get("arguments").map(|arg| {
766 if let Some(text) = arg.as_str() {
767 text.to_string()
768 } else {
769 arg.to_string()
770 }
771 });
772
773 Some(ToolCall::function(
774 id.to_string(),
775 name.to_string(),
776 arguments.unwrap_or_else(|| "{}".to_string()),
777 ))
778}
779
780#[inline]
783pub fn map_finish_reason_common(reason: &str) -> FinishReason {
784 match reason {
785 "stop" | "completed" | "done" | "finished" => FinishReason::Stop,
786 "length" => FinishReason::Length,
787 "tool_calls" => FinishReason::ToolCalls,
788 "content_filter" | "sensitive" => FinishReason::ContentFilter,
789 "refusal" => FinishReason::Refusal,
790 other => FinishReason::Error(other.to_string()),
791 }
792}
793
794const KEY_ROLE: &str = "role";
796const KEY_CONTENT: &str = "content";
797const KEY_TOOL_CALLS: &str = "tool_calls";
798const KEY_TOOL_CALL_ID: &str = "tool_call_id";
799const KEY_REASONING_CONTENT: &str = "reasoning_content";
800
801pub fn serialize_messages_openai_format(
804 request: &LLMRequest,
805 provider_key: &str,
806) -> Result<Vec<Value>, LLMError> {
807 use serde_json::{Map, json};
808
809 let mut messages = Vec::with_capacity(request.messages.len());
810
811 for message in &request.messages {
812 message
813 .validate_for_provider(provider_key)
814 .map_err(|e| LLMError::InvalidRequest {
815 message: e,
816 metadata: None,
817 })?;
818
819 let mut message_map = Map::with_capacity(4); message_map.insert(
821 KEY_ROLE.to_owned(),
822 Value::String(message.role.as_generic_str().to_owned()),
823 );
824
825 let content_value = serialize_message_content_openai_for_model(message, &request.model);
826 message_map.insert(KEY_CONTENT.to_owned(), content_value);
827
828 if let Some(tool_calls) = &message.tool_calls {
829 let serialized_calls = tool_calls
831 .iter()
832 .filter_map(|call| {
833 call.function.as_ref().map(|func| {
834 json!({
835 "id": &call.id,
836 "type": "function",
837 "function": {
838 "name": &func.name,
839 "arguments": &func.arguments
840 }
841 })
842 })
843 })
844 .collect::<Vec<_>>();
845 message_map.insert(KEY_TOOL_CALLS.to_owned(), Value::Array(serialized_calls));
846 }
847
848 if message.role == MessageRole::Tool {
849 match &message.tool_call_id {
850 Some(tool_call_id) => {
851 message_map.insert(
852 KEY_TOOL_CALL_ID.to_owned(),
853 Value::String(tool_call_id.clone()),
854 );
855 }
856 None => {
857 return Err(LLMError::InvalidRequest {
858 message: format!(
859 "Tool response message missing required tool_call_id (provider: {})",
860 provider_key
861 ),
862 metadata: None,
863 });
864 }
865 }
866 } else if let Some(tool_call_id) = &message.tool_call_id {
867 message_map.insert(
868 KEY_TOOL_CALL_ID.to_owned(),
869 Value::String(tool_call_id.clone()),
870 );
871 }
872
873 if message.role == MessageRole::Assistant
874 && let Some(reasoning) = &message.reasoning
875 {
876 message_map.insert(
877 KEY_REASONING_CONTENT.to_owned(),
878 Value::String(reasoning.clone()),
879 );
880 }
881
882 messages.push(Value::Object(message_map));
883 }
884
885 Ok(messages)
886}
887
888pub fn validate_request_common(
891 request: &LLMRequest,
892 provider_name: &str,
893 validation_provider: &str,
894 supported_models: Option<&[String]>,
895) -> Result<(), LLMError> {
896 if request.messages.is_empty() {
897 let formatted = error_display::format_llm_error(provider_name, "Messages cannot be empty");
898 return Err(LLMError::InvalidRequest {
899 message: formatted,
900 metadata: None,
901 });
902 }
903
904 if let Some(models) = supported_models
905 && !request.model.trim().is_empty()
906 && !models.contains(&request.model)
907 {
908 let msg = format!("Unsupported model: {}", request.model);
909 let formatted = error_display::format_llm_error(provider_name, &msg);
910 return Err(LLMError::InvalidRequest {
911 message: formatted,
912 metadata: None,
913 });
914 }
915
916 for message in &request.messages {
917 if let Err(err) = message.validate_for_provider(validation_provider) {
918 let formatted = error_display::format_llm_error(provider_name, &err);
919 return Err(LLMError::InvalidRequest {
920 message: formatted,
921 metadata: None,
922 });
923 }
924 }
925
926 Ok(())
927}
928
929pub fn parse_chat_request_openai_format(value: &Value, default_model: &str) -> Option<LLMRequest> {
940 parse_chat_request_openai_format_with_extractor(value, default_model, |c| {
941 c.as_str().map(|s| s.to_string()).unwrap_or_default()
942 })
943}
944
945pub fn parse_chat_request_openai_format_with_extractor<F>(
948 value: &Value,
949 default_model: &str,
950 content_extractor: F,
951) -> Option<LLMRequest>
952where
953 F: Fn(&Value) -> String,
954{
955 use crate::llm::provider::{AssistantPhase, Message};
956
957 let messages_value = value.get("messages")?.as_array()?;
958 let mut system_prompt = value
959 .get("system")
960 .and_then(|entry| entry.as_str())
961 .map(|text| text.to_string());
962 let mut messages = Vec::with_capacity(messages_value.len());
963
964 for entry in messages_value {
965 let role = entry
966 .get("role")
967 .and_then(|r| r.as_str())
968 .unwrap_or(crate::config::constants::message_roles::USER);
969 let content = entry
970 .get("content")
971 .map(&content_extractor)
972 .unwrap_or_default();
973 let assistant_phase = entry
974 .get("phase")
975 .and_then(Value::as_str)
976 .and_then(AssistantPhase::from_wire_str);
977
978 match role {
979 "system" => {
980 if system_prompt.is_none() && !content.is_empty() {
981 system_prompt = Some(content);
982 }
983 }
984 "assistant" => {
985 let tool_calls = entry
986 .get("tool_calls")
987 .and_then(|tc| tc.as_array())
988 .map(|calls| {
989 calls
990 .iter()
991 .filter_map(parse_tool_call_openai_format)
992 .collect::<Vec<_>>()
993 })
994 .filter(|calls| !calls.is_empty());
995
996 if let Some(calls) = tool_calls {
997 messages.push(
998 Message::assistant_with_tools(content, calls).with_phase(assistant_phase),
999 );
1000 } else {
1001 messages.push(Message::assistant(content).with_phase(assistant_phase));
1002 }
1003 }
1004 "tool" => {
1005 if let Some(tool_call_id) = entry.get("tool_call_id").and_then(|v| v.as_str()) {
1006 messages.push(Message::tool_response(tool_call_id.to_string(), content));
1007 }
1008 }
1009 _ => {
1010 messages.push(Message::user(content));
1011 }
1012 }
1013 }
1014
1015 Some(LLMRequest {
1016 messages,
1017 system_prompt: system_prompt.map(std::sync::Arc::new),
1018 model: value
1019 .get("model")
1020 .and_then(|m| m.as_str())
1021 .unwrap_or(default_model)
1022 .to_string(),
1023 max_tokens: value
1024 .get("max_tokens")
1025 .and_then(|m| m.as_u64())
1026 .map(|m| m as u32),
1027 temperature: value
1028 .get("temperature")
1029 .and_then(|t| t.as_f64())
1030 .map(|t| t as f32),
1031 stream: value
1032 .get("stream")
1033 .and_then(|s| s.as_bool())
1034 .unwrap_or(false),
1035 ..Default::default()
1036 })
1037}
1038
1039#[inline]
1041pub fn extract_content_from_message(message: &Value) -> Option<String> {
1042 message.get("content").and_then(|value| match value {
1043 Value::String(text) => {
1044 let trimmed = text.trim();
1045 if trimmed.is_empty() {
1046 None
1047 } else {
1048 Some(trimmed.to_string())
1049 }
1050 }
1051 Value::Array(parts) => {
1052 let mut combined = String::new();
1053 for part in parts {
1054 if let Some(text) = part.get("text").and_then(|t| t.as_str()) {
1055 combined.push_str(text);
1056 }
1057 }
1058 let trimmed = combined.trim();
1059 if trimmed.is_empty() {
1060 None
1061 } else {
1062 Some(trimmed.to_string())
1063 }
1064 }
1065 _ => None,
1066 })
1067}
1068
1069#[inline]
1071pub fn parse_usage_openai_format(
1072 response_json: &Value,
1073 include_cache_metrics: bool,
1074) -> Option<crate::llm::provider::Usage> {
1075 response_json
1076 .get("usage")
1077 .map(|usage_value| crate::llm::provider::Usage {
1078 prompt_tokens: usage_value
1079 .get("prompt_tokens")
1080 .and_then(|v| v.as_u64())
1081 .unwrap_or(0) as u32,
1082 completion_tokens: usage_value
1083 .get("completion_tokens")
1084 .and_then(|v| v.as_u64())
1085 .unwrap_or(0) as u32,
1086 total_tokens: usage_value
1087 .get("total_tokens")
1088 .and_then(|v| v.as_u64())
1089 .unwrap_or(0) as u32,
1090 cached_prompt_tokens: if include_cache_metrics {
1091 usage_value
1092 .get("prompt_cache_hit_tokens")
1093 .and_then(|v| v.as_u64())
1094 .map(|v| v as u32)
1095 } else {
1096 None
1097 },
1098 cache_creation_tokens: if include_cache_metrics {
1099 usage_value
1100 .get("prompt_cache_miss_tokens")
1101 .and_then(|v| v.as_u64())
1102 .map(|v| v as u32)
1103 } else {
1104 None
1105 },
1106 cache_read_tokens: None,
1107 })
1108}
1109
1110#[inline]
1111pub fn serialize_reasoning_detail_values(details: &[Value]) -> Option<Vec<String>> {
1112 let normalized = details
1113 .iter()
1114 .filter_map(|item| match item {
1115 Value::Null => None,
1116 Value::String(text) => {
1117 if text.trim().is_empty() {
1118 None
1119 } else {
1120 Some(text.clone())
1121 }
1122 }
1123 _ => Some(item.to_string()),
1124 })
1125 .collect::<Vec<_>>();
1126 if normalized.is_empty() {
1127 None
1128 } else {
1129 Some(normalized)
1130 }
1131}
1132
1133pub fn serialize_reasoning_details_field(details: &Value) -> Option<Vec<String>> {
1134 match details {
1135 Value::Array(items) => serialize_reasoning_detail_values(items),
1136 Value::Object(_) => Some(vec![details.to_string()]),
1137 Value::String(text) => {
1138 if text.trim().is_empty() {
1139 None
1140 } else {
1141 Some(vec![text.clone()])
1142 }
1143 }
1144 _ => None,
1145 }
1146}
1147
1148fn reasoning_text_from_detail_value(detail: &Value) -> Option<String> {
1149 let normalized = match detail {
1150 Value::Object(_) => detail.clone(),
1151 Value::String(raw) => {
1152 let trimmed = raw.trim();
1153 if (trimmed.starts_with('{') || trimmed.starts_with('['))
1154 && let Ok(parsed) = serde_json::from_str::<Value>(trimmed)
1155 {
1156 parsed
1157 } else {
1158 return None;
1159 }
1160 }
1161 _ => return None,
1162 };
1163
1164 crate::llm::providers::extract_reasoning_trace(&normalized).and_then(|trace| {
1165 let cleaned = crate::llm::providers::clean_reasoning_text(trace.trim());
1166 if cleaned.is_empty() {
1167 None
1168 } else {
1169 Some(cleaned)
1170 }
1171 })
1172}
1173
1174pub fn extract_reasoning_text_from_detail_values(details: &[Value]) -> Option<String> {
1175 let mut fragments = Vec::new();
1176 for detail in details {
1177 let Some(text) = reasoning_text_from_detail_value(detail) else {
1178 continue;
1179 };
1180 if fragments.last().is_none_or(|existing| existing != &text) {
1181 fragments.push(text);
1182 }
1183 }
1184
1185 if fragments.is_empty() {
1186 None
1187 } else {
1188 Some(fragments.join("\n\n"))
1189 }
1190}
1191
1192pub fn extract_reasoning_text_from_serialized_details(details: &[String]) -> Option<String> {
1193 let mut fragments = Vec::new();
1194 for detail in details {
1195 let Ok(parsed) = serde_json::from_str::<Value>(detail) else {
1196 continue;
1197 };
1198 let Some(text) = reasoning_text_from_detail_value(&parsed) else {
1199 continue;
1200 };
1201 if fragments.last().is_none_or(|existing| existing != &text) {
1202 fragments.push(text);
1203 }
1204 }
1205
1206 if fragments.is_empty() {
1207 None
1208 } else {
1209 Some(fragments.join("\n\n"))
1210 }
1211}
1212
1213pub fn parse_response_openai_format<F>(
1226 response_json: Value,
1227 provider_name: &str,
1228 model: String,
1229 include_cache_metrics: bool,
1230 extract_reasoning: Option<F>,
1231) -> Result<crate::llm::provider::LLMResponse, LLMError>
1232where
1233 F: Fn(&Value, &Value) -> Option<String>,
1234{
1235 use crate::llm::provider::LLMResponse;
1236
1237 let choices = response_json
1238 .get("choices")
1239 .and_then(|value| value.as_array())
1240 .ok_or_else(|| {
1241 let formatted_error = error_display::format_llm_error(
1242 provider_name,
1243 "Invalid response format: missing choices",
1244 );
1245 LLMError::Provider {
1246 message: formatted_error,
1247 metadata: None,
1248 }
1249 })?;
1250
1251 if choices.is_empty() {
1252 let formatted_error =
1253 error_display::format_llm_error(provider_name, "No choices in response");
1254 return Err(LLMError::Provider {
1255 message: formatted_error,
1256 metadata: None,
1257 });
1258 }
1259
1260 let choice = &choices[0];
1261 let message = choice.get("message").ok_or_else(|| {
1262 let formatted_error = error_display::format_llm_error(
1263 provider_name,
1264 "Invalid response format: missing message",
1265 );
1266 LLMError::Provider {
1267 message: formatted_error,
1268 metadata: None,
1269 }
1270 })?;
1271
1272 let mut content = extract_content_from_message(message);
1273
1274 let tool_calls = message
1275 .get("tool_calls")
1276 .and_then(|tc| tc.as_array())
1277 .map(|calls| {
1278 calls
1279 .iter()
1280 .filter_map(parse_tool_call_openai_format)
1281 .collect::<Vec<_>>()
1282 })
1283 .filter(|calls| !calls.is_empty());
1284
1285 let native_reasoning_details_json = message.get("reasoning_details");
1286
1287 let (mut reasoning, mut reasoning_details) = if let Some(extractor) = extract_reasoning {
1289 (extractor(message, choice), None)
1294 } else {
1295 let reasoning = message
1297 .get("reasoning_content")
1298 .or_else(|| message.get("reasoning"))
1299 .and_then(|rc| rc.as_str())
1300 .map(|s| s.to_string());
1301
1302 let reasoning_details =
1303 native_reasoning_details_json.and_then(serialize_reasoning_details_field);
1304
1305 (reasoning, reasoning_details)
1306 };
1307
1308 if reasoning.is_none()
1309 && let Some(details) = native_reasoning_details_json.and_then(|value| value.as_array())
1310 {
1311 reasoning = extract_reasoning_text_from_detail_values(details);
1312 }
1313
1314 if reasoning.is_none()
1316 && let Some(content_str) = &content
1317 && !content_str.is_empty()
1318 {
1319 let (extracted_reasoning, cleaned_content) = extract_reasoning_content(content_str);
1320 if !extracted_reasoning.is_empty() {
1321 reasoning = Some(extracted_reasoning.join("\n\n"));
1322 preserve_interleaved_content_in_reasoning_details(&mut reasoning_details, content_str);
1323 content = cleaned_content;
1325 }
1326 }
1327
1328 let finish_reason = choice
1329 .get("finish_reason")
1330 .and_then(|value| value.as_str())
1331 .map(map_finish_reason_common)
1332 .unwrap_or(FinishReason::Stop);
1333
1334 let usage = parse_usage_openai_format(&response_json, include_cache_metrics);
1335
1336 Ok(LLMResponse {
1337 content,
1338 tool_calls,
1339 model,
1340 usage,
1341 finish_reason,
1342 reasoning,
1343 reasoning_details,
1344 tool_references: Vec::new(),
1345 request_id: None,
1346 organization_id: None,
1347 compaction: None,
1348 })
1349}
1350
1351#[inline]
1361pub fn make_anthropic_thinking_config(config: &crate::config::core::AnthropicConfig) -> Value {
1362 serde_json::json!({
1363 "thinking": {
1364 "type": config.interleaved_thinking_type_enabled,
1365 "budget_tokens": config.interleaved_thinking_budget_tokens
1366 }
1367 })
1368}
1369
1370#[cfg(test)]
1371mod tests {
1372 use super::{
1373 assistant_interleaved_history_text, extract_reasoning_text_from_detail_values,
1374 extract_reasoning_text_from_serialized_details, is_interleaved_thinking_model,
1375 is_minimax_m2_model, normalize_reasoning_detail_object, parse_chat_request_openai_format,
1376 parse_response_openai_format,
1377 };
1378 use crate::llm::provider::{AssistantPhase, Message};
1379 use serde_json::{Value, json};
1380
1381 #[test]
1382 fn minimax_m2_model_detection_handles_variants() {
1383 assert!(is_minimax_m2_model("MiniMax-M2.5"));
1384 assert!(is_minimax_m2_model("minimax/minimax-m2.5"));
1385 assert!(is_minimax_m2_model("MiniMaxAI/MiniMax-M2.5:novita"));
1386 assert!(!is_minimax_m2_model("gpt-5"));
1387 }
1388
1389 #[test]
1390 fn interleaved_thinking_model_detection_handles_glm5() {
1391 assert!(is_interleaved_thinking_model("glm-5"));
1392 assert!(is_interleaved_thinking_model("zai-org/GLM-5:novita"));
1393 assert!(is_interleaved_thinking_model("MiniMax-M2.5"));
1394 assert!(!is_interleaved_thinking_model("deepseek-r1"));
1395 }
1396
1397 #[test]
1398 fn normalize_reasoning_detail_object_decodes_stringified_json_object() {
1399 let normalized = normalize_reasoning_detail_object(&json!(
1400 r#"{"type":"reasoning.text","id":"r1","text":"trace"}"#
1401 ))
1402 .expect("normalized object");
1403 assert!(normalized.is_object());
1404 assert_eq!(normalized["type"], "reasoning.text");
1405 }
1406
1407 #[test]
1408 fn normalize_reasoning_detail_object_rejects_plain_text() {
1409 assert!(normalize_reasoning_detail_object(&json!("plain-text")).is_none());
1410 }
1411
1412 #[test]
1413 fn assistant_interleaved_history_prefers_preserved_raw_detail() {
1414 let message = Message::assistant("answer".to_string())
1415 .with_reasoning_details(Some(vec![json!("<think>raw trace</think>answer")]));
1416
1417 assert_eq!(
1418 assistant_interleaved_history_text(&message, "glm-5").as_deref(),
1419 Some("<think>raw trace</think>answer")
1420 );
1421 }
1422
1423 #[test]
1424 fn assistant_interleaved_history_wraps_reasoning_when_needed() {
1425 let message =
1426 Message::assistant("answer".to_string()).with_reasoning(Some("trace".to_string()));
1427
1428 assert_eq!(
1429 assistant_interleaved_history_text(&message, "MiniMax-M2.5").as_deref(),
1430 Some("<think>trace</think>answer")
1431 );
1432 }
1433
1434 #[test]
1435 fn parse_openai_response_preserves_array_reasoning_details() {
1436 let response_json = json!({
1437 "choices": [{
1438 "message": {
1439 "content": "done",
1440 "reasoning_details": [{
1441 "type": "reasoning.text",
1442 "text": "step one"
1443 }]
1444 },
1445 "finish_reason": "stop"
1446 }],
1447 "usage": {
1448 "prompt_tokens": 1,
1449 "completion_tokens": 1,
1450 "total_tokens": 2
1451 }
1452 });
1453
1454 let parsed = parse_response_openai_format::<fn(&Value, &Value) -> Option<String>>(
1455 response_json,
1456 "test",
1457 "test-model".to_string(),
1458 false,
1459 None,
1460 )
1461 .expect("response should parse");
1462
1463 assert_eq!(parsed.reasoning.as_deref(), Some("step one"));
1464 assert!(parsed.reasoning_details.is_some());
1465 let first_detail = parsed
1466 .reasoning_details
1467 .as_ref()
1468 .and_then(|details| details.first())
1469 .expect("reasoning detail should exist");
1470 let parsed_detail: Value =
1471 serde_json::from_str(first_detail).expect("reasoning detail should be json");
1472 assert_eq!(parsed_detail["type"], "reasoning.text");
1473 }
1474
1475 #[test]
1476 fn parse_openai_response_preserves_raw_interleaved_content_in_reasoning_details() {
1477 let response_json = json!({
1478 "choices": [{
1479 "message": {
1480 "content": "<think>step one</think>done"
1481 },
1482 "finish_reason": "stop"
1483 }],
1484 "usage": {
1485 "prompt_tokens": 1,
1486 "completion_tokens": 1,
1487 "total_tokens": 2
1488 }
1489 });
1490
1491 let parsed = parse_response_openai_format::<fn(&Value, &Value) -> Option<String>>(
1492 response_json,
1493 "test",
1494 "glm-5".to_string(),
1495 false,
1496 None,
1497 )
1498 .expect("response should parse");
1499
1500 assert_eq!(parsed.content.as_deref(), Some("done"));
1501 assert_eq!(parsed.reasoning.as_deref(), Some("step one"));
1502 assert_eq!(
1503 parsed
1504 .reasoning_details
1505 .as_ref()
1506 .and_then(|details| details.first())
1507 .map(String::as_str),
1508 Some("<think>step one</think>done")
1509 );
1510 }
1511
1512 #[test]
1513 fn extract_reasoning_text_from_detail_values_handles_stringified_json() {
1514 let details = vec![json!(r#"{"type":"reasoning.text","text":"trace one"}"#)];
1515 assert_eq!(
1516 extract_reasoning_text_from_detail_values(&details).as_deref(),
1517 Some("trace one")
1518 );
1519 }
1520
1521 #[test]
1522 fn extract_reasoning_text_from_serialized_details_handles_json_items() {
1523 let details = vec![
1524 json!({"type":"reasoning.text","text":"first"}).to_string(),
1525 json!({"type":"reasoning.text","text":"second"}).to_string(),
1526 ];
1527 assert_eq!(
1528 extract_reasoning_text_from_serialized_details(&details).as_deref(),
1529 Some("first\n\nsecond")
1530 );
1531 }
1532
1533 #[test]
1534 fn parse_chat_request_openai_format_preserves_assistant_phase() {
1535 let request = parse_chat_request_openai_format(
1536 &json!({
1537 "messages": [
1538 {"role": "assistant", "content": "Working", "phase": "commentary"},
1539 {"role": "assistant", "content": "Done", "phase": "final_answer"},
1540 {"role": "user", "content": "Continue", "phase": "commentary"}
1541 ]
1542 }),
1543 "default-model",
1544 )
1545 .expect("request should parse");
1546
1547 assert_eq!(request.messages[0].phase, Some(AssistantPhase::Commentary));
1548 assert_eq!(request.messages[1].phase, Some(AssistantPhase::FinalAnswer));
1549 assert_eq!(request.messages[2].phase, None);
1550 }
1551}