Skip to main content

vtcode_core/llm/providers/
common.rs

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
13/// Collects non-empty history system directives that should be preserved when a
14/// provider accepts a separate top-level system prompt but cannot reliably
15/// consume follow-up `system` chat messages.
16pub 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
26/// Merges a base system prompt with history directives using a simple bulleted
27/// section. Providers with custom cache shaping can reuse the collected
28/// directives and apply their own section placement.
29pub 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/// Serializes tool definitions to OpenAI-compatible JSON format.
59/// Used by DeepSeek, ZAI, Moonshot, and other OpenAI-compatible providers.
60/// For OpenAI-specific features (GPT-5.1 native tools), use OpenAIProvider's serialize_tools.
61///
62/// This function normalizes all tool types to "function" type for compatibility with
63/// OpenAI-compatible APIs that don't support special tool types like "apply_patch".
64#[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                // For OpenAI-compatible APIs, normalize all tools to function type
86                // Special types like "apply_patch", "shell", "custom" are GPT-5.x specific
87                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
104/// Serialize message content for OpenAI-compatible chat payloads.
105/// Falls back to a string when there are no image parts.
106pub 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                            // Chat Completions does not accept file_url; preserve URL as text fallback.
174                            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/// Serialize message content for OpenAI-compatible payloads and normalize tool
194/// response content to plain text where required.
195#[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
208/// Serialize message content for OpenAI-compatible payloads while preserving
209/// interleaved thinking history for supported assistant models.
210pub 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/// Returns true when the model identifier points to MiniMax M2 family models.
219/// Works across direct model ids and provider-qualified ids.
220#[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/// Returns true when the model family relies on interleaved `<think>...</think>`
232/// history to maintain reasoning quality across turns.
233#[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
268/// Rehydrates assistant history into the tagged form expected by interleaved
269/// thinking models.
270pub 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
310/// Stores the exact interleaved assistant content alongside normalized
311/// reasoning so later turns can replay the original tagged history.
312pub 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
332/// Normalizes a reasoning detail into an object payload.
333/// Accepts native objects or stringified JSON objects, and rejects everything else.
334pub 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
379/// Ensures the request has a non-empty model, falling back to the provider's default.
380/// Mutates the request in place and returns the resolved model string.
381pub 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
388/// Parses a JSON response body from an HTTP response, mapping errors to
389/// `LLMError::Provider` with a formatted message including the provider name.
390pub 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
403/// Validates a request against a static list of supported model strings.
404/// Convenience wrapper around `validate_request_common` that converts
405/// `&[&str]` to `Vec<String>` internally.
406pub 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
416/// Spawns an OpenAI-compatible streaming response handler.
417/// Returns an `LLMStream` backed by a tokio task that processes chunks via
418/// `process_openai_stream` with the `handle_openai_compatible_chunk` handler.
419///
420/// Providers with custom chunk handling (e.g., DeepSeek reasoning extraction)
421/// should use the lower-level `process_openai_stream` directly.
422pub 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
480/// Implements the `LLMClient` trait for an OpenAI-compatible provider.
481/// All providers share the same pattern: create a default request, delegate to `LLMProvider::generate`.
482macro_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/// Creates a default LLM request with a single user message.
507/// Used by all providers for their LLMClient implementation.
508#[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/// Parses a client prompt that may be a JSON chat request or plain text.
518/// Returns a parsed LLMRequest from JSON if valid, or a default request with the prompt.
519#[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/// Converts provider Usage to llm_types::Usage.
535/// Shared by all LLMClient implementations.
536#[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
565/// Get or create HTTP client with custom timeouts
566pub 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
577/// Remove generation-only controls from a payload before exact prompt-token counting.
578/// Token-count endpoints generally require only prompt-side fields.
579pub 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
629/// Parse prompt/input token counts from common response shapes.
630pub 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
653/// Execute an exact token-count request when provider endpoint is available.
654/// Returns `Ok(None)` when endpoint appears unsupported.
655pub 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/// Parses a tool call from OpenAI-compatible JSON format.
759/// Works for DeepSeek, ZAI, and other OpenAI-compatible providers.
760#[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/// Maps common finish reason strings to FinishReason enum.
781/// Handles standard OpenAI-compatible finish reasons.
782#[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
794// Pre-allocated keys to avoid repeated allocations
795const 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
801/// Serializes messages to OpenAI-compatible JSON format.
802/// Used by DeepSeek, Moonshot, and other OpenAI-compatible providers.
803pub 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); // Pre-allocate for role, content, tool_calls, tool_call_id
820        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            // Optimize: Use references to avoid cloning
830            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
888/// Validates an LLM request with common checks.
889/// Checks for empty messages and validates each message for the given provider.
890pub 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
929/// Parses chat request from OpenAI-compatible JSON format.
930/// Used by DeepSeek, ZAI, OpenRouter, and other OpenAI-compatible providers.
931///
932/// # Arguments
933/// * `value` - JSON value containing the chat request
934/// * `default_model` - Default model to use if not specified in request
935/// * `content_extractor` - Optional function to extract content from JSON (defaults to simple string extraction)
936///
937/// # Returns
938/// `Some(LLMRequest)` if parsing succeeds, `None` otherwise
939pub 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
945/// Parses chat request with custom content extraction logic.
946/// Use this when provider has special content format (e.g., array of content blocks).
947pub 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/// Extracts content from a message value, handling both string and array formats.
1040#[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/// Parses usage information from OpenAI-compatible response format.
1070#[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
1213/// Parses OpenAI-compatible response format.
1214/// Used by DeepSeek, Moonshot, and other OpenAI-compatible providers.
1215///
1216/// # Arguments
1217/// * `response_json` - The JSON response from the API
1218/// * `provider_name` - Provider name for error messages
1219/// * `model` - Model name to include in the response
1220/// * `include_cache_metrics` - Whether to parse cache-related usage metrics
1221/// * `extract_reasoning` - Optional function to extract reasoning content from message/choice
1222///
1223/// # Returns
1224/// Parsed LLMResponse or error
1225pub 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    // Extract reasoning using custom extractor if provided
1288    let (mut reasoning, mut reasoning_details) = if let Some(extractor) = extract_reasoning {
1289        // Extractor should return (reasoning, reasoning_details)
1290        // For backwards compatibility, we'll wrap it if it only returns reasoning
1291        // But let's assume we update the extractor signature if needed.
1292        // For now, let's just stick to the current signature but handle it better.
1293        (extractor(message, choice), None)
1294    } else {
1295        // Default: check message.reasoning_content or choice.reasoning
1296        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    // Fallback: If no reasoning was found natively, try extracting from content
1315    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            // If the content was mostly reasoning, we update it to the cleaned version
1324            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/// Generates the interleaved thinking configuration for Anthropic models.
1352/// This provides consistent thinking configuration across all Anthropic provider implementations.
1353///
1354/// # Arguments
1355/// * `config` - Anthropic configuration containing thinking settings
1356///
1357/// Returns a JSON Value containing the thinking configuration with:
1358/// - type: Configured value (default: "enabled")
1359/// - budget_tokens: Configured value (default: 12000)
1360#[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}