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/// Returns the first present header among `names`, as an owned string.
14pub 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
23/// Converts a float parameter (temperature, top_p, …) into a JSON number,
24/// rejecting NaN and infinity with an `LLMError::InvalidRequest`.
25pub 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
32/// Collects non-empty history system directives that should be preserved when a
33/// provider accepts a separate top-level system prompt but cannot reliably
34/// consume follow-up `system` chat messages.
35pub 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
45/// Merges a base system prompt with history directives using a simple bulleted
46/// section. Providers with custom cache shaping can reuse the collected
47/// directives and apply their own section placement.
48pub 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/// Serializes tool definitions to OpenAI-compatible JSON format.
78/// Used by DeepSeek, ZAI, Moonshot, and other OpenAI-compatible providers.
79/// For OpenAI-specific features (GPT-5.1 native tools), use OpenAIProvider's serialize_tools.
80///
81/// This function normalizes all tool types to "function" type for compatibility with
82/// OpenAI-compatible APIs that don't support special tool types like "apply_patch".
83#[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                // For OpenAI-compatible APIs, normalize all tools to function type
105                // Special types like "apply_patch", "shell", "custom" are GPT-5.x specific
106                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
123/// Serialize message content for OpenAI-compatible chat payloads.
124/// Falls back to a string when there are no image parts.
125pub 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                            // Chat Completions does not accept file_url; preserve URL as text fallback.
193                            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/// Serialize message content for OpenAI-compatible payloads and normalize tool
213/// response content to plain text where required.
214#[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
227/// Serialize message content for OpenAI-compatible payloads while preserving
228/// interleaved thinking history for supported assistant models.
229pub 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/// Returns true when the model identifier points to MiniMax M2 family models.
238/// Works across direct model ids and provider-qualified ids.
239#[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/// Returns true when the model family relies on interleaved `<think>...</think>`
252/// history to maintain reasoning quality across turns.
253#[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
288/// Rehydrates assistant history into the tagged form expected by interleaved
289/// thinking models.
290pub 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
330/// Stores the exact interleaved assistant content alongside normalized
331/// reasoning so later turns can replay the original tagged history.
332pub 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
352/// Normalizes a reasoning detail into an object payload.
353/// Accepts native objects or stringified JSON objects, and rejects everything else.
354pub 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
399/// Ensures the request has a non-empty model, falling back to the provider's default.
400/// Mutates the request in place and returns the resolved model string.
401pub 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
408/// Parses a JSON response body from an HTTP response, mapping errors to
409/// `LLMError::Provider` with a formatted message including the provider name.
410pub 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
423/// Validates a request against a static list of supported model strings.
424/// Convenience wrapper around `validate_request_common` that converts
425/// `&[&str]` to `Vec<String>` internally.
426pub 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
436/// Spawns an OpenAI-compatible streaming response handler.
437/// Returns an `LLMStream` backed by a tokio task that processes chunks via
438/// `process_openai_stream` with the `handle_openai_compatible_chunk` handler.
439///
440/// Providers with custom chunk handling (e.g., DeepSeek reasoning extraction)
441/// should use the lower-level `process_openai_stream` directly.
442pub 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
500/// Implements the `LLMClient` trait for an OpenAI-compatible provider.
501/// All providers share the same pattern: create a default request, delegate to `LLMProvider::generate`.
502macro_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/// Creates a default LLM request with a single user message.
527/// Used by all providers for their LLMClient implementation.
528#[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/// Parses a client prompt that may be a JSON chat request or plain text.
538/// Returns a parsed LLMRequest from JSON if valid, or a default request with the prompt.
539#[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/// Converts provider Usage to llm_types::Usage.
555/// Shared by all LLMClient implementations.
556#[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
585/// Get or create HTTP client with custom timeouts
586pub 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
597/// Remove generation-only controls from a payload before exact prompt-token counting.
598/// Token-count endpoints generally require only prompt-side fields.
599pub 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
649/// Parse prompt/input token counts from common response shapes.
650pub 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
673/// Execute an exact token-count request when provider endpoint is available.
674/// Returns `Ok(None)` when endpoint appears unsupported.
675pub 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/// Parses a tool call from OpenAI-compatible JSON format.
779/// Works for DeepSeek, ZAI, and other OpenAI-compatible providers.
780#[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/// Maps common finish reason strings to FinishReason enum.
801/// Handles standard OpenAI-compatible finish reasons.
802#[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
814// Pre-allocated keys to avoid repeated allocations
815const 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
821/// Serializes messages to OpenAI-compatible JSON format.
822/// Used by DeepSeek, Moonshot, and other OpenAI-compatible providers.
823pub 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); // Pre-allocate for role, content, tool_calls, tool_call_id
840        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            // Optimize: Use references to avoid cloning
850            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
908/// Validates an LLM request with common checks.
909/// Checks for empty messages and validates each message for the given provider.
910pub 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
949/// Parses chat request from OpenAI-compatible JSON format.
950/// Used by DeepSeek, ZAI, OpenRouter, and other OpenAI-compatible providers.
951///
952/// # Arguments
953/// * `value` - JSON value containing the chat request
954/// * `default_model` - Default model to use if not specified in request
955/// * `content_extractor` - Optional function to extract content from JSON (defaults to simple string extraction)
956///
957/// # Returns
958/// `Some(LLMRequest)` if parsing succeeds, `None` otherwise
959pub 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
965/// Parses chat request with custom content extraction logic.
966/// Use this when provider has special content format (e.g., array of content blocks).
967pub 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/// Extracts content from a message value, handling both string and array formats.
1060#[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/// Parses usage information from OpenAI-compatible response format.
1090#[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
1234/// Parses OpenAI-compatible response format.
1235/// Used by DeepSeek, Moonshot, and other OpenAI-compatible providers.
1236///
1237/// # Arguments
1238/// * `response_json` - The JSON response from the API
1239/// * `provider_name` - Provider name for error messages
1240/// * `model` - Model name to include in the response
1241/// * `include_cache_metrics` - Whether to parse cache-related usage metrics
1242/// * `extract_reasoning` - Optional function to extract reasoning content from message/choice
1243///
1244/// # Returns
1245/// Parsed LLMResponse or error
1246pub 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    // Extract reasoning using custom extractor if provided
1309    let (mut reasoning, mut reasoning_details) = if let Some(extractor) = extract_reasoning {
1310        // Extractor should return (reasoning, reasoning_details)
1311        // For backwards compatibility, we'll wrap it if it only returns reasoning
1312        // But let's assume we update the extractor signature if needed.
1313        // For now, let's just stick to the current signature but handle it better.
1314        (extractor(message, choice), None)
1315    } else {
1316        // Default: check message.reasoning_content or choice.reasoning
1317        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    // Fallback: If no reasoning was found natively, try extracting from content
1336    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            // If the content was mostly reasoning, we update it to the cleaned version
1345            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/// Generates the interleaved thinking configuration for Anthropic models.
1373/// This provides consistent thinking configuration across all Anthropic provider implementations.
1374///
1375/// # Arguments
1376/// * `config` - Anthropic configuration containing thinking settings
1377///
1378/// Returns a JSON Value containing the thinking configuration with:
1379/// - type: Configured value (default: "enabled")
1380/// - budget_tokens: Configured value (default: 12000)
1381#[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}