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