Skip to main content

microclaw_engine/llm/
openai.rs

1use super::*;
2
3pub(crate) fn process_openai_stream_event(
4    data: &str,
5    text_tx: Option<&UnboundedSender<String>>,
6    text: &mut String,
7    reasoning_text: &mut String,
8    stop_reason: &mut Option<String>,
9    usage: &mut Option<Usage>,
10    tool_calls: &mut std::collections::BTreeMap<usize, StreamToolUseBlock>,
11) {
12    let Ok(v) = serde_json::from_str::<serde_json::Value>(data) else {
13        return;
14    };
15
16    if let Some(parsed_usage) = v.get("usage").and_then(usage_from_json).or_else(|| {
17        v.get("response")
18            .and_then(|r| r.get("usage"))
19            .and_then(usage_from_json)
20    }) {
21        merge_usage_max(usage, parsed_usage);
22    }
23
24    let Some(choice) = v
25        .get("choices")
26        .and_then(|c| c.as_array())
27        .and_then(|arr| arr.first())
28    else {
29        return;
30    };
31
32    if let Some(reason) = choice.get("finish_reason").and_then(|r| r.as_str()) {
33        *stop_reason = Some(reason.to_string());
34    }
35
36    let Some(delta) = choice.get("delta") else {
37        return;
38    };
39
40    if let Some(piece) = delta.get("content").and_then(extract_text_from_oai_value) {
41        if !piece.is_empty() {
42            text.push_str(&piece);
43            if let Some(tx) = text_tx {
44                let _ = tx.send(piece);
45            }
46        }
47    }
48
49    if let Some(piece) = delta
50        .get("thought")
51        .and_then(extract_text_from_oai_value)
52        .or_else(|| delta.get("thinking").and_then(extract_text_from_oai_value))
53    {
54        if !piece.is_empty() {
55            if reasoning_text.is_empty() {
56                debug!("AI started generating thinking/thought");
57            }
58            reasoning_text.push_str(&piece);
59        }
60    }
61
62    if let Some(piece) = delta
63        .get("reasoning_content")
64        .or_else(|| delta.get("reasoning_details"))
65        .and_then(extract_text_from_oai_value)
66    {
67        if !piece.is_empty() {
68            if reasoning_text.is_empty() {
69                debug!("AI started generating reasoning_content");
70            }
71            reasoning_text.push_str(&piece);
72        }
73    }
74
75    if let Some(tc_arr) = delta.get("tool_calls").and_then(|v| v.as_array()) {
76        for tc in tc_arr {
77            let index = if let Some(i) = tc.get("index").and_then(|i| i.as_u64()) {
78                usize::try_from(i).ok()
79            } else if let Some(id) = tc.get("id").and_then(|v| v.as_str()) {
80                tool_calls
81                    .iter()
82                    .find(|(_, entry)| entry.id == id)
83                    .map(|(idx, _)| *idx)
84            } else {
85                None
86            };
87
88            let index =
89                index.unwrap_or_else(|| tool_calls.keys().last().map(|last| last + 1).unwrap_or(0));
90
91            let entry = tool_calls.entry(index).or_default();
92            if let Some(id) = tc.get("id").and_then(|v| v.as_str()) {
93                if !id.is_empty() {
94                    entry.id = id.to_string();
95                }
96            }
97            if let Some(function) = tc.get("function") {
98                if let Some(name) = function.get("name").and_then(|v| v.as_str()) {
99                    if !name.is_empty() {
100                        entry.name = name.to_string();
101                    }
102                }
103                if let Some(args) = function.get("arguments") {
104                    match args {
105                        serde_json::Value::String(s) => {
106                            if !s.is_empty() {
107                                entry.input_json.push_str(s);
108                            }
109                        }
110                        serde_json::Value::Null => {}
111                        other => entry.input_json.push_str(&other.to_string()),
112                    }
113                }
114                if let Some(sig) = function.get("thought_signature").and_then(|s| s.as_str()) {
115                    entry.thought_signature = Some(sig.to_string());
116                }
117            }
118            if let Some(sig) = tc
119                .get("extra_content")
120                .and_then(|e| e.get("google"))
121                .and_then(|g| g.get("thought_signature"))
122                .and_then(|s| s.as_str())
123            {
124                entry.thought_signature = Some(sig.to_string());
125            }
126        }
127    }
128}
129
130pub struct OpenAiProvider {
131    http: reqwest::Client,
132    key_pool: KeyPool,
133    codex_account_id: Option<String>,
134    provider: String,
135    model: String,
136    max_tokens: u32,
137    is_openai_codex: bool,
138    enable_reasoning_content_bridge: bool,
139    enable_thinking_param: bool,
140    show_thinking: bool,
141    prefer_max_completion_tokens: bool,
142    openai_compat_body_overrides: HashMap<String, serde_json::Value>,
143    openai_compat_body_overrides_by_provider: HashMap<String, HashMap<String, serde_json::Value>>,
144    openai_compat_body_overrides_by_model: HashMap<String, HashMap<String, serde_json::Value>>,
145    chat_url: String,
146    responses_url: String,
147}
148
149pub(crate) fn resolve_openai_compat_base(provider: &str, configured_base: &str) -> String {
150    let trimmed = configured_base.trim().trim_end_matches('/').to_string();
151    if is_openai_codex_provider(provider) {
152        if let Some(codex_base) = codex_config_default_openai_base_url() {
153            return codex_base.trim_end_matches('/').to_string();
154        }
155        return "https://chatgpt.com/backend-api/codex".to_string();
156    }
157
158    if trimmed.is_empty() {
159        default_base_url_for_provider(provider)
160            .unwrap_or("https://api.openai.com/v1")
161            .to_string()
162    } else {
163        trimmed
164    }
165}
166
167impl OpenAiProvider {
168    pub fn new(config: &Config) -> Self {
169        let is_openai_codex = is_openai_codex_provider(&config.llm_provider);
170        let is_deepseek_provider = config.llm_provider.eq_ignore_ascii_case("deepseek");
171        let is_google_provider = config.llm_provider.eq_ignore_ascii_case("google");
172        let enable_reasoning_content_bridge =
173            is_google_provider || (config.show_thinking && is_deepseek_provider);
174        let enable_thinking_param =
175            (is_deepseek_provider || is_google_provider) && config.show_thinking;
176        let configured_base = config.llm_base_url.as_deref().unwrap_or("");
177        let base = resolve_openai_compat_base(&config.llm_provider, configured_base);
178
179        let (api_key, codex_account_id) = if is_openai_codex {
180            let _ = refresh_openai_codex_auth_if_needed();
181            match resolve_openai_codex_auth("") {
182                Ok(auth) => (auth.bearer_token, auth.account_id),
183                Err(e) => {
184                    warn!("{}", e);
185                    (String::new(), None)
186                }
187            }
188        } else if is_qwen_portal_provider(&config.llm_provider) && config.api_key.trim().is_empty()
189        {
190            match resolve_qwen_portal_auth("") {
191                Ok(auth) => (auth.bearer_token, None),
192                Err(e) => {
193                    warn!("{}", e);
194                    (String::new(), None)
195                }
196            }
197        } else {
198            (config.api_key.clone(), None)
199        };
200
201        OpenAiProvider {
202            http: reqwest::Client::builder()
203                .user_agent(llm_user_agent(&config.llm_user_agent))
204                .build()
205                .unwrap_or_else(|e| {
206                    warn!("Failed to build LLM HTTP client with user-agent: {e}");
207                    reqwest::Client::new()
208                }),
209            key_pool: KeyPool::new(&api_key, &config.api_keys),
210            codex_account_id,
211            provider: config.llm_provider.clone(),
212            model: config.model.clone(),
213            max_tokens: config.max_tokens,
214            is_openai_codex,
215            enable_reasoning_content_bridge,
216            enable_thinking_param,
217            show_thinking: config.show_thinking,
218            prefer_max_completion_tokens: config.llm_provider.eq_ignore_ascii_case("openai"),
219            openai_compat_body_overrides: config.openai_compat_body_overrides.clone(),
220            openai_compat_body_overrides_by_provider: config
221                .openai_compat_body_overrides_by_provider
222                .clone(),
223            openai_compat_body_overrides_by_model: config
224                .openai_compat_body_overrides_by_model
225                .clone(),
226            chat_url: format!("{}/chat/completions", base.trim_end_matches('/')),
227            responses_url: format!("{}/responses", base.trim_end_matches('/')),
228        }
229    }
230}
231
232pub(crate) fn maybe_enable_thinking_param(
233    body: &mut serde_json::Value,
234    provider: &str,
235    enabled: bool,
236) {
237    if !enabled {
238        return;
239    }
240    if let Some(obj) = body.as_object_mut() {
241        match provider.to_ascii_lowercase().as_str() {
242            "google" => {
243                obj.remove("thinking");
244                obj.remove("thinking_config");
245                let extra_body = obj
246                    .entry("extra_body".to_string())
247                    .or_insert_with(|| json!({}));
248                if !extra_body.is_object() {
249                    *extra_body = json!({});
250                }
251                if let Some(extra_obj) = extra_body.as_object_mut() {
252                    let google = extra_obj
253                        .entry("google".to_string())
254                        .or_insert_with(|| json!({}));
255                    if !google.is_object() {
256                        *google = json!({});
257                    }
258                    if let Some(google_obj) = google.as_object_mut() {
259                        google_obj.insert(
260                            "thinking_config".to_string(),
261                            json!({"include_thoughts": true}),
262                        );
263                    }
264                }
265            }
266            "deepseek" => {
267                obj.insert("thinking".to_string(), json!({"type": "enabled"}));
268            }
269            // Alibaba DashScope (Qwen OpenAI-compatible): enable_thinking controls mixed thinking mode.
270            "alibaba" => {
271                obj.insert("enable_thinking".to_string(), json!(true));
272            }
273            // MiniMax OpenAI-compatible: reasoning_split separates thinking into reasoning_details.
274            "minimax" => {
275                obj.insert("reasoning_split".to_string(), json!(true));
276            }
277            // OpenRouter unified reasoning config.
278            "openrouter" => {
279                obj.insert("reasoning".to_string(), json!({}));
280            }
281            _ => {
282                error!(
283                    provider = provider,
284                    "show_thinking is enabled, but no supported thinking parameter mapping is configured for this provider"
285                );
286            }
287        }
288    }
289}
290
291pub(crate) fn apply_body_override_map(
292    body: &mut serde_json::Value,
293    overrides: Option<&HashMap<String, serde_json::Value>>,
294) {
295    let Some(overrides) = overrides else {
296        return;
297    };
298    let Some(obj) = body.as_object_mut() else {
299        return;
300    };
301    for (key, value) in overrides {
302        if value.is_null() {
303            obj.remove(key);
304        } else {
305            obj.insert(key.clone(), value.clone());
306        }
307    }
308}
309
310pub(crate) fn apply_openai_compat_body_overrides(
311    body: &mut serde_json::Value,
312    provider: &str,
313    model: &str,
314    global: &HashMap<String, serde_json::Value>,
315    by_provider: &HashMap<String, HashMap<String, serde_json::Value>>,
316    by_model: &HashMap<String, HashMap<String, serde_json::Value>>,
317) {
318    apply_body_override_map(body, Some(global));
319    apply_body_override_map(body, by_provider.get(&provider.to_ascii_lowercase()));
320    apply_body_override_map(body, by_model.get(model));
321}
322
323pub(crate) fn resolve_request_model(
324    provider: &str,
325    configured_model: &str,
326    model_override: Option<&str>,
327) -> String {
328    resolve_model_name_with_fallback(provider, model_override, Some(configured_model))
329}
330
331pub(crate) fn has_visible_reply_runtime_guard(messages: &[Message]) -> bool {
332    messages.iter().rev().any(|m| {
333        if m.role != "user" {
334            return false;
335        }
336        match &m.content {
337            MessageContent::Text(t) => {
338                t.contains("[runtime_guard]: Your previous reply had no user-visible text.")
339            }
340            MessageContent::Blocks(blocks) => blocks.iter().any(|b| match b {
341                ContentBlock::Text { text } => {
342                    text.contains("[runtime_guard]: Your previous reply had no user-visible text.")
343                }
344                _ => false,
345            }),
346        }
347    })
348}
349
350// --- OpenAI response types ---
351
352#[derive(Debug, Deserialize)]
353pub(crate) struct OaiResponse {
354    #[serde(default, deserialize_with = "deserialize_oai_choices")]
355    pub(crate) choices: Vec<OaiChoice>,
356    pub(crate) usage: Option<OaiUsage>,
357}
358
359pub(crate) fn deserialize_oai_choices<'de, D>(deserializer: D) -> Result<Vec<OaiChoice>, D::Error>
360where
361    D: serde::Deserializer<'de>,
362{
363    Ok(Option::<Vec<OaiChoice>>::deserialize(deserializer)?.unwrap_or_default())
364}
365
366pub(crate) fn deserialize_optional_oai_text<'de, D>(
367    deserializer: D,
368) -> Result<Option<String>, D::Error>
369where
370    D: serde::Deserializer<'de>,
371{
372    let value = Option::<serde_json::Value>::deserialize(deserializer)?;
373    Ok(value.and_then(|v| extract_text_from_oai_value(&v)))
374}
375
376pub(crate) fn extract_text_from_oai_value(value: &serde_json::Value) -> Option<String> {
377    match value {
378        serde_json::Value::Null => None,
379        serde_json::Value::String(text) => Some(text.clone()),
380        serde_json::Value::Array(items) => {
381            let combined = items
382                .iter()
383                .filter_map(extract_text_from_oai_value)
384                .collect::<Vec<_>>()
385                .join("");
386            if combined.is_empty() {
387                None
388            } else {
389                Some(combined)
390            }
391        }
392        serde_json::Value::Object(map) => map
393            .get("text")
394            .and_then(|v| v.as_str())
395            .map(|v| v.to_string())
396            .or_else(|| map.get("content").and_then(extract_text_from_oai_value))
397            .or_else(|| map.get("parts").and_then(extract_text_from_oai_value))
398            .or_else(|| map.get("value").and_then(extract_text_from_oai_value)),
399        _ => None,
400    }
401}
402
403#[derive(Debug, Deserialize)]
404pub(crate) struct OaiChoice {
405    pub(crate) message: OaiMessage,
406    pub(crate) finish_reason: Option<String>,
407}
408
409#[derive(Debug, Deserialize)]
410pub(crate) struct OaiMessage {
411    #[serde(default, deserialize_with = "deserialize_optional_oai_text")]
412    pub(crate) content: Option<String>,
413    #[serde(
414        default,
415        alias = "reasoning_details",
416        deserialize_with = "deserialize_optional_oai_text"
417    )]
418    pub(crate) reasoning_content: Option<String>,
419    pub(crate) tool_calls: Option<Vec<OaiToolCall>>,
420}
421
422#[derive(Debug, Deserialize)]
423pub(crate) struct OaiToolCall {
424    pub(crate) id: String,
425    pub(crate) function: OaiFunction,
426    #[serde(default)]
427    pub(crate) extra_content: Option<serde_json::Value>,
428}
429
430#[derive(Debug, Deserialize)]
431pub(crate) struct OaiFunction {
432    pub(crate) name: String,
433    pub(crate) arguments: String,
434    #[serde(default)]
435    pub(crate) thought_signature: Option<String>,
436}
437
438#[derive(Debug, Deserialize)]
439pub(crate) struct OaiUsage {
440    pub(crate) prompt_tokens: u32,
441    pub(crate) completion_tokens: u32,
442}
443
444#[derive(Debug, Deserialize)]
445pub(crate) struct OaiErrorResponse {
446    pub(crate) error: OaiErrorDetail,
447}
448
449#[derive(Debug, Deserialize)]
450pub(crate) struct OaiErrorDetail {
451    pub(crate) message: String,
452    #[serde(default)]
453    pub(crate) code: Option<serde_json::Value>,
454    #[serde(default)]
455    pub(crate) r#type: Option<String>,
456    #[serde(default)]
457    pub(crate) metadata: Option<serde_json::Value>,
458}
459
460impl OaiErrorDetail {
461    fn display(&self) -> String {
462        let mut parts = Vec::new();
463        if let Some(code) = &self.code {
464            let code_str = match code {
465                serde_json::Value::Number(n) => n.to_string(),
466                serde_json::Value::String(s) => s.clone(),
467                other => other.to_string(),
468            };
469            parts.push(code_str);
470        }
471        if let Some(t) = &self.r#type {
472            if !t.is_empty() {
473                parts.push(t.clone());
474            }
475        }
476        let prefix = if parts.is_empty() {
477            String::new()
478        } else {
479            format!("{}: ", parts.join(" "))
480        };
481        // OpenRouter includes upstream error details in metadata.raw
482        let raw_detail = self
483            .metadata
484            .as_ref()
485            .and_then(|m| m.get("raw"))
486            .and_then(|v| v.as_str())
487            .unwrap_or("");
488        if raw_detail.is_empty() {
489            format!("{prefix}{}", self.message)
490        } else {
491            format!("{prefix}{} — {raw_detail}", self.message)
492        }
493    }
494}
495
496pub(crate) fn should_retry_with_max_completion_tokens(error_text: &str) -> bool {
497    if let Ok(value) = serde_json::from_str::<serde_json::Value>(error_text) {
498        let param_is_max_tokens = value
499            .get("error")
500            .and_then(|e| e.get("param"))
501            .and_then(|p| p.as_str())
502            .map(|p| p == "max_tokens")
503            .unwrap_or(false);
504        if param_is_max_tokens {
505            return true;
506        }
507    }
508
509    let lower = error_text.to_ascii_lowercase();
510    lower.contains("max_tokens") && lower.contains("max_completion_tokens")
511}
512
513pub(crate) fn should_retry_without_stream_options(error_text: &str) -> bool {
514    let lower = error_text.to_ascii_lowercase();
515    (lower.contains("stream_options") || lower.contains("include_usage"))
516        && (lower.contains("unsupported")
517            || lower.contains("unknown")
518            || lower.contains("invalid")
519            || lower.contains("not supported")
520            || lower.contains("unrecognized"))
521}
522
523pub(crate) fn switch_to_max_completion_tokens(body: &mut serde_json::Value) -> bool {
524    if body.get("max_completion_tokens").is_some() {
525        return false;
526    }
527    let Some(max_tokens) = body.get("max_tokens").cloned() else {
528        return false;
529    };
530    if let Some(obj) = body.as_object_mut() {
531        obj.remove("max_tokens");
532        obj.insert("max_completion_tokens".to_string(), max_tokens);
533        return true;
534    }
535    false
536}
537
538pub(crate) fn set_output_token_limit(
539    body: &mut serde_json::Value,
540    max_tokens: u32,
541    prefer_max_completion_tokens: bool,
542) {
543    if let Some(obj) = body.as_object_mut() {
544        obj.remove("max_tokens");
545        obj.remove("max_completion_tokens");
546        let key = if prefer_max_completion_tokens {
547            "max_completion_tokens"
548        } else {
549            "max_tokens"
550        };
551        obj.insert(key.to_string(), json!(max_tokens));
552    }
553}
554
555#[derive(Debug, Deserialize)]
556pub(crate) struct OaiResponsesResponse {
557    pub(crate) output: Vec<OaiResponsesOutputItem>,
558    pub(crate) usage: Option<OaiResponsesUsage>,
559}
560
561#[derive(Debug, Deserialize)]
562pub(crate) struct OaiResponsesUsage {
563    pub(crate) input_tokens: u32,
564    pub(crate) output_tokens: u32,
565}
566
567#[derive(Debug, Deserialize)]
568#[serde(tag = "type")]
569pub(crate) enum OaiResponsesOutputItem {
570    #[serde(rename = "message")]
571    Message {
572        content: Vec<OaiResponsesOutputContentPart>,
573    },
574    #[serde(rename = "function_call")]
575    FunctionCall {
576        id: Option<String>,
577        call_id: Option<String>,
578        name: String,
579        arguments: String,
580    },
581    #[serde(other)]
582    Other,
583}
584
585#[derive(Debug, Deserialize)]
586#[serde(tag = "type")]
587pub(crate) enum OaiResponsesOutputContentPart {
588    #[serde(rename = "output_text")]
589    OutputText { text: String },
590    #[serde(other)]
591    Other,
592}
593
594#[async_trait]
595impl LlmProvider for OpenAiProvider {
596    async fn send_message(
597        &self,
598        system: &str,
599        messages: Vec<Message>,
600        tools: Option<Vec<ToolDefinition>>,
601    ) -> Result<MessagesResponse, MicroClawError> {
602        self.send_message_with_model(system, messages, tools, None)
603            .await
604    }
605
606    async fn send_message_with_model(
607        &self,
608        system: &str,
609        messages: Vec<Message>,
610        tools: Option<Vec<ToolDefinition>>,
611        model_override: Option<&str>,
612    ) -> Result<MessagesResponse, MicroClawError> {
613        let model = resolve_request_model(&self.provider, &self.model, model_override);
614        if self.is_openai_codex {
615            return self
616                .send_codex_message(system, messages, tools, &model)
617                .await;
618        }
619
620        let sanitized = sanitize_messages(messages);
621        let oai_messages = if self.enable_reasoning_content_bridge {
622            translate_messages_to_oai_with_reasoning(system, &sanitized, true)
623        } else {
624            translate_messages_to_oai(system, &sanitized)
625        };
626
627        let mut body = json!({
628            "model": model,
629            "messages": oai_messages,
630        });
631        set_output_token_limit(
632            &mut body,
633            self.max_tokens,
634            self.prefer_max_completion_tokens,
635        );
636        let thinking_enabled =
637            self.enable_thinking_param && !has_visible_reply_runtime_guard(&sanitized);
638        maybe_enable_thinking_param(&mut body, &self.provider, thinking_enabled);
639        apply_openai_compat_body_overrides(
640            &mut body,
641            &self.provider,
642            &self.model,
643            &self.openai_compat_body_overrides,
644            &self.openai_compat_body_overrides_by_provider,
645            &self.openai_compat_body_overrides_by_model,
646        );
647        if let Some(obj) = body.as_object_mut() {
648            obj.remove("stream");
649        }
650
651        if let Some(ref tool_defs) = tools {
652            if !tool_defs.is_empty() {
653                body["tools"] = json!(translate_tools_to_oai(tool_defs));
654            }
655        }
656
657        let mut retries = 0u32;
658        let max_retries = 3;
659
660        debug!(
661            provider = %self.provider,
662            model = %model,
663            url = %self.chat_url,
664            has_api_key = !self.key_pool.current().trim().is_empty(),
665            "Sending LLM request"
666        );
667
668        loop {
669            let mut req = self
670                .http
671                .post(&self.chat_url)
672                .header("Content-Type", "application/json")
673                .json(&body);
674            let api_key = self.key_pool.current();
675            if !api_key.trim().is_empty() {
676                req = req.header("Authorization", format!("Bearer {api_key}"));
677            }
678            let response = match req.send().await {
679                Ok(r) => r,
680                Err(e) if is_retryable_transport_error(&e) && retries < max_retries => {
681                    retries += 1;
682                    let delay = retry_backoff(retries, None);
683                    warn!(
684                        "LLM transport error ({e}), retrying in {delay:?} (attempt {retries}/{max_retries})"
685                    );
686                    tokio::time::sleep(delay).await;
687                    continue;
688                }
689                Err(e) => return Err(e.into()),
690            };
691
692            let status = response.status();
693
694            if status.is_success() {
695                let text = response.text().await?;
696                let oai: OaiResponse = serde_json::from_str(&text).map_err(|e| {
697                    MicroClawError::LlmApi(format!(
698                        "Failed to parse OpenAI response: {e}\nBody: {text}"
699                    ))
700                })?;
701                return Ok(translate_oai_response_with_display_reasoning(
702                    oai,
703                    self.show_thinking,
704                ));
705            }
706
707            let rotated = is_key_rotation_status(status.as_u16())
708                && retries < max_retries
709                && self.key_pool.advance();
710            if rotated {
711                warn!(
712                    "Auth/rate-limit error (HTTP {status}); rotated to next API key ({} in pool)",
713                    self.key_pool.len()
714                );
715            }
716            if (is_retryable_status(status.as_u16()) || rotated) && retries < max_retries {
717                retries += 1;
718                let retry_after = parse_retry_after(response.headers());
719                let delay = if rotated && !is_retryable_status(status.as_u16()) {
720                    std::time::Duration::from_millis(200)
721                } else {
722                    retry_backoff(retries, retry_after)
723                };
724                warn!(
725                    "Transient LLM error (HTTP {status}), retrying in {delay:?} (attempt {retries}/{max_retries})"
726                );
727                tokio::time::sleep(delay).await;
728                continue;
729            }
730
731            let text = response.text().await.unwrap_or_default();
732            if should_retry_with_max_completion_tokens(&text)
733                && switch_to_max_completion_tokens(&mut body)
734            {
735                warn!(
736                    "OpenAI-compatible API rejected max_tokens; retrying with max_completion_tokens"
737                );
738                continue;
739            }
740            if let Ok(err) = serde_json::from_str::<OaiErrorResponse>(&text) {
741                return Err(MicroClawError::LlmApi(format!(
742                    "{} (url={})",
743                    err.error.display(),
744                    self.chat_url
745                )));
746            }
747            return Err(MicroClawError::LlmApi(format!(
748                "HTTP {status} {}: {text}",
749                self.chat_url
750            )));
751        }
752    }
753
754    async fn send_message_stream(
755        &self,
756        system: &str,
757        messages: Vec<Message>,
758        tools: Option<Vec<ToolDefinition>>,
759        text_tx: Option<&UnboundedSender<String>>,
760    ) -> Result<MessagesResponse, MicroClawError> {
761        self.send_message_stream_with_model(system, messages, tools, text_tx, None)
762            .await
763    }
764
765    async fn send_message_stream_with_model(
766        &self,
767        system: &str,
768        messages: Vec<Message>,
769        tools: Option<Vec<ToolDefinition>>,
770        text_tx: Option<&UnboundedSender<String>>,
771        model_override: Option<&str>,
772    ) -> Result<MessagesResponse, MicroClawError> {
773        let model = resolve_request_model(&self.provider, &self.model, model_override);
774        if self.is_openai_codex {
775            let response = self
776                .send_codex_message(system, messages, tools, &model)
777                .await?;
778            if let Some(tx) = text_tx {
779                let text = response
780                    .content
781                    .iter()
782                    .filter_map(|block| match block {
783                        ResponseContentBlock::Text { text } => Some(text.as_str()),
784                        _ => None,
785                    })
786                    .collect::<Vec<_>>()
787                    .join("");
788                if !text.is_empty() {
789                    let _ = tx.send(text);
790                }
791            }
792            return Ok(response);
793        }
794
795        let sanitized = sanitize_messages(messages);
796        let oai_messages = if self.enable_reasoning_content_bridge {
797            translate_messages_to_oai_with_reasoning(system, &sanitized, true)
798        } else {
799            translate_messages_to_oai(system, &sanitized)
800        };
801
802        let mut body = json!({
803            "model": model,
804            "messages": oai_messages,
805            "stream": true,
806        });
807        set_output_token_limit(
808            &mut body,
809            self.max_tokens,
810            self.prefer_max_completion_tokens,
811        );
812        let thinking_enabled =
813            self.enable_thinking_param && !has_visible_reply_runtime_guard(&sanitized);
814        maybe_enable_thinking_param(&mut body, &self.provider, thinking_enabled);
815        apply_openai_compat_body_overrides(
816            &mut body,
817            &self.provider,
818            &self.model,
819            &self.openai_compat_body_overrides,
820            &self.openai_compat_body_overrides_by_provider,
821            &self.openai_compat_body_overrides_by_model,
822        );
823        body["stream"] = json!(true);
824        if body.get("stream_options").is_none() {
825            body["stream_options"] = json!({
826                "include_usage": true
827            });
828        } else if let Some(obj) = body
829            .get_mut("stream_options")
830            .and_then(|v| v.as_object_mut())
831        {
832            obj.entry("include_usage".to_string())
833                .or_insert_with(|| json!(true));
834        }
835
836        if let Some(ref tool_defs) = tools {
837            if !tool_defs.is_empty() {
838                body["tools"] = json!(translate_tools_to_oai(tool_defs));
839            }
840        }
841
842        debug!(
843            provider = %self.provider,
844            model = %model,
845            url = %self.chat_url,
846            messages_count = sanitized.len(),
847            "Sending LLM stream request"
848        );
849
850        let response = loop {
851            let mut req = self
852                .http
853                .post(&self.chat_url)
854                .header("Content-Type", "application/json")
855                .json(&body);
856            let api_key = self.key_pool.current();
857            if !api_key.trim().is_empty() {
858                req = req.header("Authorization", format!("Bearer {api_key}"));
859            }
860            let response = req.send().await?;
861            let status = response.status();
862            if status.is_success() {
863                break response;
864            }
865
866            let text = response.text().await.unwrap_or_default();
867            if should_retry_with_max_completion_tokens(&text)
868                && switch_to_max_completion_tokens(&mut body)
869            {
870                warn!(
871                    "OpenAI-compatible API rejected max_tokens; retrying stream with max_completion_tokens"
872                );
873                continue;
874            }
875            if body.get("stream_options").is_some() && should_retry_without_stream_options(&text) {
876                if let Some(obj) = body.as_object_mut() {
877                    obj.remove("stream_options");
878                }
879                warn!(
880                    "OpenAI-compatible API rejected stream_options/include_usage; retrying stream without stream_options"
881                );
882                continue;
883            }
884            if let Ok(err) = serde_json::from_str::<OaiErrorResponse>(&text) {
885                return Err(MicroClawError::LlmApi(format!(
886                    "{} (url={})",
887                    err.error.display(),
888                    self.chat_url
889                )));
890            }
891            return Err(MicroClawError::LlmApi(format!(
892                "HTTP {status} {}: {text}",
893                self.chat_url
894            )));
895        };
896
897        let mut byte_stream = response.bytes_stream();
898        let mut sse = SseEventParser::default();
899        let mut text = String::new();
900        let mut reasoning_text = String::new();
901        let mut stop_reason: Option<String> = None;
902        let mut usage: Option<Usage> = None;
903        let mut tool_calls: std::collections::BTreeMap<usize, StreamToolUseBlock> =
904            std::collections::BTreeMap::new();
905
906        'outer: while let Some(chunk_res) = byte_stream.next().await {
907            let chunk = match chunk_res {
908                Ok(c) => c,
909                Err(_) => break,
910            };
911            for data in sse.push_chunk(chunk.as_ref()) {
912                if data == "[DONE]" {
913                    break 'outer;
914                }
915                process_openai_stream_event(
916                    &data,
917                    text_tx,
918                    &mut text,
919                    &mut reasoning_text,
920                    &mut stop_reason,
921                    &mut usage,
922                    &mut tool_calls,
923                );
924            }
925        }
926        for data in sse.finish() {
927            if data == "[DONE]" {
928                break;
929            }
930            process_openai_stream_event(
931                &data,
932                text_tx,
933                &mut text,
934                &mut reasoning_text,
935                &mut stop_reason,
936                &mut usage,
937                &mut tool_calls,
938            );
939        }
940
941        let mut content = Vec::new();
942        let mut visible_text =
943            combine_response_text_for_display(&text, &reasoning_text, self.show_thinking);
944        let mut raw_text_tool_calls = None;
945        if let Some(parsed_raw_calls) = extract_raw_tool_use_blocks(&visible_text) {
946            if tool_calls.is_empty() {
947                raw_text_tool_calls = Some(parsed_raw_calls);
948            }
949            visible_text.clear();
950        }
951        if !visible_text.is_empty() {
952            content.push(ResponseContentBlock::Text { text: visible_text });
953        }
954        for tool in tool_calls.values() {
955            content.push(ResponseContentBlock::ToolUse {
956                id: sanitize_tool_id(&tool.id),
957                name: tool.name.clone(),
958                input: parse_tool_input(&tool.input_json),
959                thought_signature: tool.thought_signature.clone(),
960            });
961        }
962        if let Some(parsed_raw_calls) = raw_text_tool_calls {
963            for tool in parsed_raw_calls {
964                content.push(ResponseContentBlock::ToolUse {
965                    id: sanitize_tool_id(&tool.id),
966                    name: tool.name,
967                    input: parse_tool_input(&tool.input_json),
968                    thought_signature: tool.thought_signature,
969                });
970            }
971        }
972        if content.is_empty() {
973            content.push(ResponseContentBlock::Text {
974                text: String::new(),
975            });
976        }
977
978        let mut normalized_stop_reason = normalize_stop_reason(stop_reason);
979        if !tool_calls.is_empty() {
980            normalized_stop_reason = Some("tool_use".to_string());
981        }
982
983        Ok(MessagesResponse {
984            content,
985            stop_reason: normalized_stop_reason,
986            usage,
987        })
988    }
989}
990
991impl OpenAiProvider {
992    async fn send_codex_message(
993        &self,
994        system: &str,
995        messages: Vec<Message>,
996        tools: Option<Vec<ToolDefinition>>,
997        model: &str,
998    ) -> Result<MessagesResponse, MicroClawError> {
999        let instructions = if system.trim().is_empty() {
1000            "You are a helpful assistant."
1001        } else {
1002            system
1003        };
1004        let mut input = translate_messages_to_oai_responses_input(&messages);
1005        if input.is_empty() {
1006            input.push(json!({
1007                "type": "message",
1008                "role": "user",
1009                "content": "",
1010            }));
1011        }
1012        let mut body = json!({
1013            "model": model,
1014            "input": input,
1015            "instructions": instructions,
1016            "store": false,
1017            "stream": true,
1018        });
1019        apply_openai_compat_body_overrides(
1020            &mut body,
1021            &self.provider,
1022            &self.model,
1023            &self.openai_compat_body_overrides,
1024            &self.openai_compat_body_overrides_by_provider,
1025            &self.openai_compat_body_overrides_by_model,
1026        );
1027        body["stream"] = json!(true);
1028        if let Some(ref tool_defs) = tools {
1029            if !tool_defs.is_empty() {
1030                body["tools"] = json!(translate_tools_to_oai_responses(tool_defs));
1031                body["tool_choice"] = json!("auto");
1032            }
1033        }
1034
1035        let mut retries = 0u32;
1036        let max_retries = 3;
1037
1038        loop {
1039            let mut req = self
1040                .http
1041                .post(&self.responses_url)
1042                .header("Content-Type", "application/json")
1043                .json(&body);
1044            let api_key = self.key_pool.current();
1045            if !api_key.trim().is_empty() {
1046                req = req.header("Authorization", format!("Bearer {api_key}"));
1047            }
1048            if let Some(account_id) = self.codex_account_id.as_deref() {
1049                if !account_id.trim().is_empty() {
1050                    req = req.header("ChatGPT-Account-ID", account_id);
1051                }
1052            }
1053            let response = match req.send().await {
1054                Ok(r) => r,
1055                Err(e) if is_retryable_transport_error(&e) && retries < max_retries => {
1056                    retries += 1;
1057                    let delay = retry_backoff(retries, None);
1058                    warn!(
1059                        "LLM transport error ({e}), retrying in {delay:?} (attempt {retries}/{max_retries})"
1060                    );
1061                    tokio::time::sleep(delay).await;
1062                    continue;
1063                }
1064                Err(e) => return Err(e.into()),
1065            };
1066            let status = response.status();
1067
1068            if status.is_success() {
1069                let text = response.text().await?;
1070                let parsed = parse_openai_codex_response_payload(&text)?;
1071                return Ok(translate_oai_responses_response(parsed));
1072            }
1073
1074            let rotated = is_key_rotation_status(status.as_u16())
1075                && retries < max_retries
1076                && self.key_pool.advance();
1077            if rotated {
1078                warn!(
1079                    "Auth/rate-limit error (HTTP {status}); rotated to next API key ({} in pool)",
1080                    self.key_pool.len()
1081                );
1082            }
1083            if (is_retryable_status(status.as_u16()) || rotated) && retries < max_retries {
1084                retries += 1;
1085                let retry_after = parse_retry_after(response.headers());
1086                let delay = if rotated && !is_retryable_status(status.as_u16()) {
1087                    std::time::Duration::from_millis(200)
1088                } else {
1089                    retry_backoff(retries, retry_after)
1090                };
1091                warn!(
1092                    "Transient LLM error (HTTP {status}), retrying in {delay:?} (attempt {retries}/{max_retries})"
1093                );
1094                tokio::time::sleep(delay).await;
1095                continue;
1096            }
1097
1098            let text = response.text().await.unwrap_or_default();
1099            if let Ok(err) = serde_json::from_str::<OaiErrorResponse>(&text) {
1100                return Err(MicroClawError::LlmApi(err.error.display()));
1101            }
1102            return Err(MicroClawError::LlmApi(format!("HTTP {status}: {text}")));
1103        }
1104    }
1105}
1106
1107pub(crate) fn parse_openai_codex_response_payload(
1108    text: &str,
1109) -> Result<OaiResponsesResponse, MicroClawError> {
1110    if let Ok(parsed) = serde_json::from_str::<OaiResponsesResponse>(text) {
1111        return Ok(parsed);
1112    }
1113
1114    let mut from_done_event: Option<OaiResponsesResponse> = None;
1115    let mut streamed_output_text = String::new();
1116    let mut streamed_function_calls = Vec::new();
1117    let mut pending_event_type: Option<&str> = None;
1118    for line in text.lines() {
1119        let line = line.trim();
1120        if let Some(event_type) = line.strip_prefix("event:") {
1121            pending_event_type = Some(event_type.trim());
1122            continue;
1123        }
1124        if !line.starts_with("data:") {
1125            continue;
1126        }
1127        let payload = line.trim_start_matches("data:").trim();
1128        if payload.is_empty() || payload == "[DONE]" {
1129            continue;
1130        }
1131        let Ok(value) = serde_json::from_str::<serde_json::Value>(payload) else {
1132            continue;
1133        };
1134        let event_type = value
1135            .get("type")
1136            .and_then(|value| value.as_str())
1137            .or(pending_event_type);
1138
1139        match event_type {
1140            Some("response.output_text.delta") => {
1141                if let Some(delta) = value.get("delta").and_then(|value| value.as_str()) {
1142                    streamed_output_text.push_str(delta);
1143                }
1144            }
1145            Some("response.output_text.done") if streamed_output_text.is_empty() => {
1146                if let Some(done) = value.get("text").and_then(|value| value.as_str()) {
1147                    streamed_output_text.push_str(done);
1148                }
1149            }
1150            Some("response.output_item.done") => {
1151                if let Some(item) = value.get("item") {
1152                    if let Ok(OaiResponsesOutputItem::FunctionCall {
1153                        id,
1154                        call_id,
1155                        name,
1156                        arguments,
1157                    }) = serde_json::from_value::<OaiResponsesOutputItem>(item.clone())
1158                    {
1159                        streamed_function_calls.push(OaiResponsesOutputItem::FunctionCall {
1160                            id,
1161                            call_id,
1162                            name,
1163                            arguments,
1164                        });
1165                    }
1166                }
1167            }
1168            _ => {}
1169        }
1170
1171        if let Some(response_value) = value.get("response") {
1172            if let Ok(parsed) =
1173                serde_json::from_value::<OaiResponsesResponse>(response_value.clone())
1174            {
1175                from_done_event = Some(parsed);
1176                if matches!(event_type, Some("response.done" | "response.completed")) {
1177                    break;
1178                }
1179            }
1180        }
1181        pending_event_type = None;
1182    }
1183
1184    if let Some(mut parsed) = from_done_event {
1185        if !streamed_function_calls.is_empty()
1186            && !parsed
1187                .output
1188                .iter()
1189                .any(|item| matches!(item, OaiResponsesOutputItem::FunctionCall { .. }))
1190        {
1191            parsed.output.append(&mut streamed_function_calls);
1192        }
1193        if !streamed_output_text.is_empty()
1194            && !parsed.output.iter().any(|item| match item {
1195                OaiResponsesOutputItem::Message { content } => content.iter().any(|part| {
1196                    matches!(part, OaiResponsesOutputContentPart::OutputText { text } if !text.is_empty())
1197                }),
1198                _ => false,
1199            })
1200        {
1201            parsed.output.push(OaiResponsesOutputItem::Message {
1202                content: vec![OaiResponsesOutputContentPart::OutputText {
1203                    text: streamed_output_text,
1204                }],
1205            });
1206        }
1207        return Ok(parsed);
1208    }
1209
1210    if !streamed_output_text.is_empty() || !streamed_function_calls.is_empty() {
1211        let mut output = streamed_function_calls;
1212        if !streamed_output_text.is_empty() {
1213            output.push(OaiResponsesOutputItem::Message {
1214                content: vec![OaiResponsesOutputContentPart::OutputText {
1215                    text: streamed_output_text,
1216                }],
1217            });
1218        }
1219        return Ok(OaiResponsesResponse {
1220            output,
1221            usage: None,
1222        });
1223    }
1224
1225    Err(MicroClawError::LlmApi(format!(
1226        "Failed to parse OpenAI Codex response payload. Body: {text}"
1227    )))
1228}
1229
1230// ---------------------------------------------------------------------------
1231// Format translation helpers  (internal Anthropic-style ↔ OpenAI)
1232// ---------------------------------------------------------------------------
1233
1234#[cfg(test)]
1235mod tests {
1236    use super::*;
1237    #[allow(unused_imports)]
1238    use crate::llm::test_prelude::*;
1239
1240    #[test]
1241    fn test_translate_oai_response_tool_calls_legacy_function_thought_signature() {
1242        let raw = r#"{"choices":[{"message":{"content":null,"reasoning_content":null,"tool_calls":[{"id":"call_1","function":{"name":"bash","arguments":"{\"command\":\"ls\"}","thought_signature":"sig_legacy"}}]},"finish_reason":"tool_calls"}],"usage":null}"#;
1243        let oai: OaiResponse = serde_json::from_str(raw).unwrap();
1244        let resp = translate_oai_response(oai);
1245        match &resp.content[0] {
1246            ResponseContentBlock::ToolUse {
1247                thought_signature, ..
1248            } => assert_eq!(thought_signature.as_deref(), Some("sig_legacy")),
1249            _ => panic!("Expected ToolUse"),
1250        }
1251    }
1252
1253    #[test]
1254    fn test_translate_oai_response_text() {
1255        let oai = OaiResponse {
1256            choices: vec![OaiChoice {
1257                message: OaiMessage {
1258                    content: Some("Hello!".into()),
1259                    reasoning_content: None,
1260                    tool_calls: None,
1261                },
1262                finish_reason: Some("stop".into()),
1263            }],
1264            usage: Some(OaiUsage {
1265                prompt_tokens: 10,
1266                completion_tokens: 5,
1267            }),
1268        };
1269        let resp = translate_oai_response(oai);
1270        assert_eq!(resp.stop_reason.as_deref(), Some("end_turn"));
1271        assert_eq!(resp.content.len(), 1);
1272        match &resp.content[0] {
1273            ResponseContentBlock::Text { text } => assert_eq!(text, "Hello!"),
1274            _ => panic!("Expected Text"),
1275        }
1276        let usage = resp.usage.unwrap();
1277        assert_eq!(usage.input_tokens, 10);
1278        assert_eq!(usage.output_tokens, 5);
1279    }
1280
1281    #[test]
1282    fn test_translate_oai_response_tool_calls() {
1283        let oai = OaiResponse {
1284            choices: vec![OaiChoice {
1285                message: OaiMessage {
1286                    content: None,
1287                    reasoning_content: None,
1288                    tool_calls: Some(vec![OaiToolCall {
1289                        id: "call_1".into(),
1290                        function: OaiFunction {
1291                            name: "bash".into(),
1292                            arguments: r#"{"command":"ls"}"#.into(),
1293                            thought_signature: None,
1294                        },
1295                        extra_content: None,
1296                    }]),
1297                },
1298                finish_reason: Some("tool_calls".into()),
1299            }],
1300            usage: None,
1301        };
1302        let resp = translate_oai_response(oai);
1303        assert_eq!(resp.stop_reason.as_deref(), Some("tool_use"));
1304        match &resp.content[0] {
1305            ResponseContentBlock::ToolUse {
1306                id,
1307                name,
1308                input,
1309                thought_signature,
1310            } => {
1311                assert_eq!(id, "call_1");
1312                assert_eq!(name, "bash");
1313                assert_eq!(input["command"], "ls");
1314                assert!(thought_signature.is_none());
1315            }
1316            _ => panic!("Expected ToolUse"),
1317        }
1318    }
1319
1320    #[test]
1321    fn test_translate_oai_response_tool_calls_without_calls_downgrades_to_end_turn() {
1322        let oai = OaiResponse {
1323            choices: vec![OaiChoice {
1324                message: OaiMessage {
1325                    content: None,
1326                    reasoning_content: None,
1327                    tool_calls: None,
1328                },
1329                finish_reason: Some("tool_calls".into()),
1330            }],
1331            usage: None,
1332        };
1333
1334        let resp = translate_oai_response(oai);
1335        assert_eq!(resp.stop_reason.as_deref(), Some("end_turn"));
1336        assert!(!resp
1337            .content
1338            .iter()
1339            .any(|b| matches!(b, ResponseContentBlock::ToolUse { .. })));
1340    }
1341
1342    #[test]
1343    fn test_translate_oai_response_empty_choices() {
1344        let oai = OaiResponse {
1345            choices: vec![],
1346            usage: None,
1347        };
1348        let resp = translate_oai_response(oai);
1349        assert_eq!(resp.stop_reason.as_deref(), Some("end_turn"));
1350        match &resp.content[0] {
1351            ResponseContentBlock::Text { text } => assert_eq!(text, "(empty response)"),
1352            _ => panic!("Expected Text"),
1353        }
1354    }
1355
1356    #[test]
1357    fn test_deserialize_oai_response_null_choices() {
1358        let raw = r#"{"id":"x","choices":null,"usage":null}"#;
1359        let parsed: OaiResponse = serde_json::from_str(raw).unwrap();
1360        assert!(parsed.choices.is_empty());
1361    }
1362
1363    #[test]
1364    fn test_translate_oai_response_length_stop() {
1365        let oai = OaiResponse {
1366            choices: vec![OaiChoice {
1367                message: OaiMessage {
1368                    content: Some("partial".into()),
1369                    reasoning_content: None,
1370                    tool_calls: None,
1371                },
1372                finish_reason: Some("length".into()),
1373            }],
1374            usage: None,
1375        };
1376        let resp = translate_oai_response(oai);
1377        assert_eq!(resp.stop_reason.as_deref(), Some("max_tokens"));
1378    }
1379
1380    #[test]
1381    fn test_translate_oai_response_text_and_tool_calls() {
1382        let oai = OaiResponse {
1383            choices: vec![OaiChoice {
1384                message: OaiMessage {
1385                    content: Some("thinking...".into()),
1386                    reasoning_content: None,
1387                    tool_calls: Some(vec![OaiToolCall {
1388                        id: "c1".into(),
1389                        function: OaiFunction {
1390                            name: "read_file".into(),
1391                            arguments: r#"{"path":"/tmp/x"}"#.into(),
1392                            thought_signature: None,
1393                        },
1394                        extra_content: None,
1395                    }]),
1396                },
1397                finish_reason: Some("tool_calls".into()),
1398            }],
1399            usage: None,
1400        };
1401        let resp = translate_oai_response(oai);
1402        assert_eq!(resp.content.len(), 2);
1403        match &resp.content[0] {
1404            ResponseContentBlock::Text { text } => assert_eq!(text, "thinking..."),
1405            _ => panic!("Expected Text"),
1406        }
1407        match &resp.content[1] {
1408            ResponseContentBlock::ToolUse { name, .. } => assert_eq!(name, "read_file"),
1409            _ => panic!("Expected ToolUse"),
1410        }
1411    }
1412
1413    #[test]
1414    fn test_translate_oai_response_reasoning_content_and_tool_calls() {
1415        let oai = OaiResponse {
1416            choices: vec![OaiChoice {
1417                message: OaiMessage {
1418                    content: None,
1419                    reasoning_content: Some("plan".into()),
1420                    tool_calls: Some(vec![OaiToolCall {
1421                        id: "c1".into(),
1422                        function: OaiFunction {
1423                            name: "bash".into(),
1424                            arguments: r#"{"command":"ls"}"#.into(),
1425                            thought_signature: None,
1426                        },
1427                        extra_content: None,
1428                    }]),
1429                },
1430                finish_reason: Some("tool_calls".into()),
1431            }],
1432            usage: None,
1433        };
1434        let resp = translate_oai_response(oai);
1435        assert_eq!(resp.content.len(), 2);
1436        match &resp.content[0] {
1437            ResponseContentBlock::Text { text } => {
1438                assert_eq!(text, "<thought>\nplan\n</thought>")
1439            }
1440            _ => panic!("Expected Text"),
1441        }
1442        match &resp.content[1] {
1443            ResponseContentBlock::ToolUse { name, .. } => assert_eq!(name, "bash"),
1444            _ => panic!("Expected ToolUse"),
1445        }
1446    }
1447
1448    #[test]
1449    fn test_translate_oai_response_accepts_structured_content_arrays() {
1450        let raw = r#"{"choices":[{"message":{"content":[{"type":"text","text":"Hello "},{"type":"text","text":"Gemini"}],"reasoning_content":[{"type":"text","text":"plan "},{"type":"text","text":"steps"}],"tool_calls":null},"finish_reason":"stop"}],"usage":null}"#;
1451        let oai: OaiResponse = serde_json::from_str(raw).unwrap();
1452        let resp = translate_oai_response(oai);
1453        match &resp.content[0] {
1454            ResponseContentBlock::Text { text } => {
1455                assert_eq!(text, "<thought>\nplan steps\n</thought>\n\nHello Gemini")
1456            }
1457            _ => panic!("Expected Text"),
1458        }
1459    }
1460
1461    #[test]
1462    fn test_translate_oai_response_reasoning_only() {
1463        let oai = OaiResponse {
1464            choices: vec![OaiChoice {
1465                message: OaiMessage {
1466                    content: None,
1467                    reasoning_content: Some("internal".into()),
1468                    tool_calls: None,
1469                },
1470                finish_reason: Some("stop".into()),
1471            }],
1472            usage: None,
1473        };
1474        let resp = translate_oai_response(oai);
1475        match &resp.content[0] {
1476            ResponseContentBlock::Text { text } => {
1477                assert_eq!(text, "<thought>\ninternal\n</thought>")
1478            }
1479            _ => panic!("Expected Text"),
1480        }
1481    }
1482
1483    #[test]
1484    fn test_translate_oai_response_omits_reasoning_when_disabled() {
1485        let oai = OaiResponse {
1486            choices: vec![OaiChoice {
1487                message: OaiMessage {
1488                    content: Some("Visible".into()),
1489                    reasoning_content: Some("internal".into()),
1490                    tool_calls: None,
1491                },
1492                finish_reason: Some("stop".into()),
1493            }],
1494            usage: None,
1495        };
1496        let resp = translate_oai_response_with_display_reasoning(oai, false);
1497        match &resp.content[0] {
1498            ResponseContentBlock::Text { text } => assert_eq!(text, "Visible"),
1499            _ => panic!("Expected Text"),
1500        }
1501    }
1502
1503    #[test]
1504    fn test_process_openai_stream_event_collects_reasoning_content() {
1505        let data = r#"{"choices":[{"delta":{"reasoning_content":"think","tool_calls":[{"index":0,"id":"c1","function":{"name":"bash","arguments":"{\"command\":\"ls\"}","thought_signature":"sig_123"}}]},"finish_reason":null}],"usage":null}"#;
1506        let mut text = String::new();
1507        let mut reasoning_text = String::new();
1508        let mut stop_reason = None;
1509        let mut usage = None;
1510        let mut tool_calls = std::collections::BTreeMap::new();
1511
1512        process_openai_stream_event(
1513            data,
1514            None,
1515            &mut text,
1516            &mut reasoning_text,
1517            &mut stop_reason,
1518            &mut usage,
1519            &mut tool_calls,
1520        );
1521
1522        assert!(text.is_empty());
1523        assert_eq!(reasoning_text, "think");
1524        assert_eq!(stop_reason, None);
1525        let call = tool_calls.get(&0).unwrap();
1526        assert_eq!(call.id, "c1");
1527        assert_eq!(call.name, "bash");
1528        assert_eq!(call.input_json, r#"{"command":"ls"}"#);
1529        assert_eq!(call.thought_signature.as_deref(), Some("sig_123"));
1530    }
1531
1532    #[test]
1533    fn test_process_openai_stream_event_collects_reasoning_details_alias() {
1534        let data = r#"{"choices":[{"delta":{"reasoning_details":"step-by-step"}}],"usage":null}"#;
1535        let mut text = String::new();
1536        let mut reasoning_text = String::new();
1537        let mut stop_reason = None;
1538        let mut usage = None;
1539        let mut tool_calls = std::collections::BTreeMap::new();
1540
1541        process_openai_stream_event(
1542            data,
1543            None,
1544            &mut text,
1545            &mut reasoning_text,
1546            &mut stop_reason,
1547            &mut usage,
1548            &mut tool_calls,
1549        );
1550
1551        assert!(text.is_empty());
1552        assert_eq!(reasoning_text, "step-by-step");
1553        assert!(tool_calls.is_empty());
1554    }
1555
1556    #[test]
1557    fn test_process_openai_stream_event_accepts_structured_delta_content() {
1558        let data = r#"{"choices":[{"delta":{"content":[{"type":"text","text":"Hello "},{"type":"text","text":"Gemini"}],"reasoning_content":[{"type":"text","text":"plan"},{"type":"text","text":" more"}]}}],"usage":null}"#;
1559        let mut text = String::new();
1560        let mut reasoning_text = String::new();
1561        let mut stop_reason = None;
1562        let mut usage = None;
1563        let mut tool_calls = std::collections::BTreeMap::new();
1564
1565        process_openai_stream_event(
1566            data,
1567            None,
1568            &mut text,
1569            &mut reasoning_text,
1570            &mut stop_reason,
1571            &mut usage,
1572            &mut tool_calls,
1573        );
1574
1575        assert_eq!(text, "Hello Gemini");
1576        assert_eq!(reasoning_text, "plan more");
1577        assert_eq!(stop_reason, None);
1578        assert!(usage.is_none());
1579        assert!(tool_calls.is_empty());
1580    }
1581
1582    #[test]
1583    fn test_process_openai_stream_event_collects_thinking_aliases() {
1584        let data = r#"{"choices":[{"delta":{"thought":"alpha","thinking":"beta"}}]}"#;
1585        let mut text = String::new();
1586        let mut reasoning_text = String::new();
1587        let mut stop_reason = None;
1588        let mut usage = None;
1589        let mut tool_calls = std::collections::BTreeMap::new();
1590
1591        process_openai_stream_event(
1592            data,
1593            None,
1594            &mut text,
1595            &mut reasoning_text,
1596            &mut stop_reason,
1597            &mut usage,
1598            &mut tool_calls,
1599        );
1600
1601        assert!(text.is_empty());
1602        assert_eq!(reasoning_text, "alpha");
1603        assert_eq!(stop_reason, None);
1604        assert!(usage.is_none());
1605        assert!(tool_calls.is_empty());
1606    }
1607
1608    #[test]
1609    fn test_process_openai_stream_event_tool_calls_without_index_and_object_args() {
1610        let data = r#"{"choices":[{"delta":{"tool_calls":[{"id":"call_1","function":{"name":"weather","arguments":{"location":"Shanghai"}}}]}}]}"#;
1611        let mut text = String::new();
1612        let mut reasoning_text = String::new();
1613        let mut stop_reason = None;
1614        let mut usage = None;
1615        let mut tool_calls = std::collections::BTreeMap::new();
1616
1617        process_openai_stream_event(
1618            data,
1619            None,
1620            &mut text,
1621            &mut reasoning_text,
1622            &mut stop_reason,
1623            &mut usage,
1624            &mut tool_calls,
1625        );
1626
1627        assert!(text.is_empty());
1628        assert!(reasoning_text.is_empty());
1629        assert_eq!(stop_reason, None);
1630        let call = tool_calls.get(&0).unwrap();
1631        assert_eq!(call.id, "call_1");
1632        assert_eq!(call.name, "weather");
1633        assert_eq!(call.input_json, r#"{"location":"Shanghai"}"#);
1634    }
1635
1636    #[test]
1637    fn test_process_openai_stream_event_ignores_minimax_malformed_trailing_tool_chunks() {
1638        let first = r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_ok","type":"function","function":{"name":"get_oil_price","arguments":""}}]}}]}"#;
1639        let second = r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"","arguments":"{"}}]}}]}"#;
1640        let third = r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"","arguments":"}"}}]}}]}"#;
1641        let fourth = r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"arguments":null}}]}}]}"#;
1642        let mut text = String::new();
1643        let mut reasoning_text = String::new();
1644        let mut stop_reason = None;
1645        let mut usage = None;
1646        let mut tool_calls = std::collections::BTreeMap::new();
1647
1648        for data in [first, second, third, fourth] {
1649            process_openai_stream_event(
1650                data,
1651                None,
1652                &mut text,
1653                &mut reasoning_text,
1654                &mut stop_reason,
1655                &mut usage,
1656                &mut tool_calls,
1657            );
1658        }
1659
1660        let call = tool_calls.get(&0).unwrap();
1661        assert_eq!(call.id, "call_ok");
1662        assert_eq!(call.name, "get_oil_price");
1663        assert_eq!(call.input_json, "{}");
1664    }
1665
1666    #[test]
1667    fn test_process_openai_stream_event_ignores_qwen_malformed_trailing_tool_chunks() {
1668        let first = r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_ok","type":"function","function":{"name":"get_oil_price","arguments":""}}]}}]}"#;
1669        let second = r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"arguments":"{}"}}]}}]}"#;
1670        let third = r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"arguments":""}}]}}]}"#;
1671        let mut text = String::new();
1672        let mut reasoning_text = String::new();
1673        let mut stop_reason = None;
1674        let mut usage = None;
1675        let mut tool_calls = std::collections::BTreeMap::new();
1676
1677        for data in [first, second, third] {
1678            process_openai_stream_event(
1679                data,
1680                None,
1681                &mut text,
1682                &mut reasoning_text,
1683                &mut stop_reason,
1684                &mut usage,
1685                &mut tool_calls,
1686            );
1687        }
1688
1689        let call = tool_calls.get(&0).unwrap();
1690        assert_eq!(call.id, "call_ok");
1691        assert_eq!(call.name, "get_oil_price");
1692        assert_eq!(call.input_json, "{}");
1693    }
1694
1695    #[test]
1696    fn test_process_openai_stream_event_updates_usage_with_max_values() {
1697        let first = r#"{"choices":[{"delta":{"content":"a"}}],"usage":{"prompt_tokens":10,"completion_tokens":0}}"#;
1698        let second = r#"{"choices":[{"delta":{"content":"b"}}],"usage":{"prompt_tokens":10,"completion_tokens":7}}"#;
1699        let mut text = String::new();
1700        let mut reasoning_text = String::new();
1701        let mut stop_reason = None;
1702        let mut usage = None;
1703        let mut tool_calls = std::collections::BTreeMap::new();
1704
1705        process_openai_stream_event(
1706            first,
1707            None,
1708            &mut text,
1709            &mut reasoning_text,
1710            &mut stop_reason,
1711            &mut usage,
1712            &mut tool_calls,
1713        );
1714        process_openai_stream_event(
1715            second,
1716            None,
1717            &mut text,
1718            &mut reasoning_text,
1719            &mut stop_reason,
1720            &mut usage,
1721            &mut tool_calls,
1722        );
1723
1724        let usage = usage.expect("usage should exist");
1725        assert_eq!(usage.input_tokens, 10);
1726        assert_eq!(usage.output_tokens, 7);
1727    }
1728
1729    #[test]
1730    fn test_process_openai_stream_event_parses_response_done_usage() {
1731        let data = r#"{"type":"response.done","response":{"usage":{"input_tokens":11,"output_tokens":5}}}"#;
1732        let mut text = String::new();
1733        let mut reasoning_text = String::new();
1734        let mut stop_reason = None;
1735        let mut usage = None;
1736        let mut tool_calls = std::collections::BTreeMap::new();
1737
1738        process_openai_stream_event(
1739            data,
1740            None,
1741            &mut text,
1742            &mut reasoning_text,
1743            &mut stop_reason,
1744            &mut usage,
1745            &mut tool_calls,
1746        );
1747
1748        let usage = usage.expect("usage should exist");
1749        assert_eq!(usage.input_tokens, 11);
1750        assert_eq!(usage.output_tokens, 5);
1751    }
1752
1753    #[test]
1754    fn test_should_retry_with_max_completion_tokens() {
1755        let err = r#"{"error":{"message":"Unsupported parameter: 'max_tokens' is not supported with this model. Use 'max_completion_tokens' instead.","param":"max_tokens"}}"#;
1756        assert!(should_retry_with_max_completion_tokens(err));
1757        assert!(!should_retry_with_max_completion_tokens(
1758            r#"{"error":{"message":"bad request","param":"messages"}}"#
1759        ));
1760    }
1761
1762    #[test]
1763    fn test_switch_to_max_completion_tokens() {
1764        let mut body = json!({"model":"gpt-5.2","max_tokens":128});
1765        assert!(switch_to_max_completion_tokens(&mut body));
1766        assert_eq!(body.get("max_tokens"), None);
1767        assert_eq!(body["max_completion_tokens"], 128);
1768        assert!(!switch_to_max_completion_tokens(&mut body));
1769    }
1770
1771    #[test]
1772    fn test_maybe_enable_thinking_param_enabled() {
1773        let mut body = json!({"model":"test-model","messages":[]});
1774        maybe_enable_thinking_param(&mut body, "deepseek", true);
1775        assert_eq!(body["thinking"]["type"], "enabled");
1776        assert!(body.get("thinking_config").is_none());
1777    }
1778
1779    #[test]
1780    fn test_maybe_enable_thinking_param_disabled() {
1781        let mut body = json!({"model":"test-model","messages":[]});
1782        maybe_enable_thinking_param(&mut body, "deepseek", false);
1783        assert!(body.get("thinking").is_none());
1784        assert!(body.get("thinking_config").is_none());
1785    }
1786
1787    #[test]
1788    fn test_maybe_enable_thinking_param_google_uses_thinking_config() {
1789        let mut body = json!({"model":"gemini-2.5-flash","messages":[]});
1790        maybe_enable_thinking_param(&mut body, "google", true);
1791        assert!(body.get("thinking").is_none());
1792        assert!(body.get("thinking_config").is_none());
1793        assert_eq!(
1794            body["extra_body"]["google"]["thinking_config"]["include_thoughts"],
1795            true
1796        );
1797    }
1798
1799    #[test]
1800    fn test_maybe_enable_thinking_param_unknown_provider_does_not_set_fields() {
1801        let mut body = json!({"model":"unknown","messages":[]});
1802        maybe_enable_thinking_param(&mut body, "openrouter", true);
1803        assert!(body.get("reasoning").is_some());
1804    }
1805
1806    #[test]
1807    fn test_maybe_enable_thinking_param_alibaba_uses_enable_thinking() {
1808        let mut body = json!({"model":"qwen-plus","messages":[]});
1809        maybe_enable_thinking_param(&mut body, "alibaba", true);
1810        assert_eq!(body["enable_thinking"], true);
1811        assert!(body.get("thinking").is_none());
1812    }
1813
1814    #[test]
1815    fn test_maybe_enable_thinking_param_minimax_uses_reasoning_split() {
1816        let mut body = json!({"model":"MiniMax-M2.5","messages":[]});
1817        maybe_enable_thinking_param(&mut body, "minimax", true);
1818        assert_eq!(body["reasoning_split"], true);
1819    }
1820
1821    #[test]
1822    fn test_maybe_enable_thinking_param_unsupported_provider_does_not_set_fields() {
1823        let mut body = json!({"model":"unknown","messages":[]});
1824        maybe_enable_thinking_param(&mut body, "qwen-portal", true);
1825        assert!(body.get("thinking").is_none());
1826        assert!(body.get("thinking_config").is_none());
1827        assert!(body.get("enable_thinking").is_none());
1828        assert!(body.get("reasoning_split").is_none());
1829        assert!(body.get("reasoning").is_none());
1830    }
1831
1832    #[test]
1833    fn test_has_visible_reply_runtime_guard_detects_guard_message() {
1834        let msgs = vec![Message {
1835            role: "user".into(),
1836            content: MessageContent::Text(
1837                "[runtime_guard]: Your previous reply had no user-visible text. Reply again now."
1838                    .into(),
1839            ),
1840        }];
1841        assert!(has_visible_reply_runtime_guard(&msgs));
1842    }
1843
1844    #[test]
1845    fn test_apply_openai_compat_body_overrides_merges_global_provider_model() {
1846        let mut body = json!({
1847            "model": "gpt-5.2",
1848            "temperature": 0.5,
1849            "top_p": 0.9,
1850            "stream": false
1851        });
1852        let mut global = HashMap::new();
1853        global.insert("temperature".into(), json!(0.2));
1854        global.insert("seed".into(), json!(42));
1855        let mut by_provider = HashMap::new();
1856        by_provider.insert(
1857            "openai".into(),
1858            HashMap::from([("top_p".into(), json!(0.8))]),
1859        );
1860        let mut by_model = HashMap::new();
1861        by_model.insert(
1862            "gpt-5.2".into(),
1863            HashMap::from([("stream".into(), json!(true))]),
1864        );
1865
1866        apply_openai_compat_body_overrides(
1867            &mut body,
1868            "openai",
1869            "gpt-5.2",
1870            &global,
1871            &by_provider,
1872            &by_model,
1873        );
1874
1875        assert_eq!(body["temperature"], 0.2);
1876        assert_eq!(body["top_p"], 0.8);
1877        assert_eq!(body["seed"], 42);
1878        assert_eq!(body["stream"], true);
1879    }
1880
1881    #[test]
1882    fn test_apply_openai_compat_body_overrides_null_unsets_key() {
1883        let mut body = json!({"temperature": 0.7, "top_p": 0.9});
1884        let mut by_provider = HashMap::new();
1885        by_provider.insert(
1886            "deepseek".into(),
1887            HashMap::from([("top_p".into(), serde_json::Value::Null)]),
1888        );
1889
1890        apply_openai_compat_body_overrides(
1891            &mut body,
1892            "deepseek",
1893            "deepseek-chat",
1894            &HashMap::new(),
1895            &by_provider,
1896            &HashMap::new(),
1897        );
1898
1899        assert!(body.get("top_p").is_none());
1900        assert_eq!(body["temperature"], 0.7);
1901    }
1902
1903    #[test]
1904    fn test_openai_provider_capability_flags_for_deepseek() {
1905        let mut config = Config::test_defaults();
1906        config.llm_provider = "deepseek".into();
1907        config.model = "test-model".into();
1908        config.show_thinking = true;
1909        config.data_dir = "/tmp".into();
1910        config.working_dir = "/tmp".into();
1911        config.working_dir_isolation = WorkingDirIsolation::Shared;
1912        config.web_enabled = false;
1913        config.web_port = 3900;
1914
1915        let provider = OpenAiProvider::new(&config);
1916        assert!(provider.enable_thinking_param);
1917        assert!(provider.enable_reasoning_content_bridge);
1918    }
1919
1920    #[test]
1921    fn test_openai_provider_capability_flags_for_google() {
1922        let mut config = Config::test_defaults();
1923        config.llm_provider = "google".into();
1924        config.model = "gemini-3-flash-preview".into();
1925        config.show_thinking = true;
1926        config.data_dir = "/tmp".into();
1927        config.working_dir = "/tmp".into();
1928        config.working_dir_isolation = WorkingDirIsolation::Shared;
1929        config.web_enabled = false;
1930        config.web_port = 3900;
1931
1932        let provider = OpenAiProvider::new(&config);
1933        assert!(provider.enable_thinking_param);
1934        assert!(provider.enable_reasoning_content_bridge);
1935    }
1936
1937    #[test]
1938    fn test_openai_provider_disables_google_thinking_param_when_show_thinking_is_false() {
1939        let mut config = Config::test_defaults();
1940        config.llm_provider = "google".into();
1941        config.model = "gemini-3-flash-preview".into();
1942        config.show_thinking = false;
1943        config.data_dir = "/tmp".into();
1944        config.working_dir = "/tmp".into();
1945        config.working_dir_isolation = WorkingDirIsolation::Shared;
1946        config.web_enabled = false;
1947        config.web_port = 3900;
1948
1949        let provider = OpenAiProvider::new(&config);
1950        assert!(!provider.enable_thinking_param);
1951        assert!(!provider.show_thinking);
1952        assert!(provider.enable_reasoning_content_bridge);
1953    }
1954
1955    #[test]
1956    fn test_set_output_token_limit_prefers_max_completion_tokens() {
1957        let mut body = json!({"model":"gpt-5.2","messages":[],"max_tokens":1});
1958        set_output_token_limit(&mut body, 256, true);
1959        assert_eq!(body.get("max_tokens"), None);
1960        assert_eq!(body["max_completion_tokens"], 256);
1961    }
1962
1963    #[test]
1964    fn test_set_output_token_limit_uses_max_tokens_for_compat() {
1965        let mut body = json!({"model":"qwen","messages":[],"max_completion_tokens":1});
1966        set_output_token_limit(&mut body, 512, false);
1967        assert_eq!(body.get("max_completion_tokens"), None);
1968        assert_eq!(body["max_tokens"], 512);
1969    }
1970
1971    #[tokio::test]
1972    #[allow(clippy::await_holding_lock)]
1973    async fn test_openai_codex_stream_uses_responses_endpoint() {
1974        let _guard = env_lock();
1975        let prev_access = std::env::var("OPENAI_CODEX_ACCESS_TOKEN").ok();
1976        let prev_codex_home = std::env::var("CODEX_HOME").ok();
1977        std::env::set_var("OPENAI_CODEX_ACCESS_TOKEN", "oauth-token");
1978
1979        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1980        let addr = listener.local_addr().unwrap();
1981        let codex_home = std::env::temp_dir().join(format!(
1982            "microclaw-codex-home-oauth-{}",
1983            std::time::SystemTime::now()
1984                .duration_since(std::time::UNIX_EPOCH)
1985                .unwrap_or_default()
1986                .as_nanos()
1987        ));
1988        std::fs::create_dir_all(&codex_home).unwrap();
1989        std::fs::write(
1990            codex_home.join("config.toml"),
1991            format!(
1992                "model_provider = \"test\"\n\n[model_providers.test]\nbase_url = \"http://{}\"\n",
1993                addr
1994            ),
1995        )
1996        .unwrap();
1997        std::env::set_var("CODEX_HOME", &codex_home);
1998        let (request_tx, request_rx) = mpsc::channel::<(String, Option<String>)>();
1999
2000        let server = std::thread::spawn(move || {
2001            let (mut stream, _) = listener.accept().unwrap();
2002            stream
2003                .set_read_timeout(Some(Duration::from_secs(2)))
2004                .unwrap();
2005
2006            let mut buf = [0u8; 8192];
2007            let n = stream.read(&mut buf).unwrap_or(0);
2008            let req = String::from_utf8_lossy(&buf[..n]).to_string();
2009            let path = req
2010                .lines()
2011                .next()
2012                .and_then(|line| line.split_whitespace().nth(1))
2013                .unwrap_or("")
2014                .to_string();
2015            let auth_header = req.lines().find_map(|line| {
2016                let lower = line.to_ascii_lowercase();
2017                if lower.starts_with("authorization:") {
2018                    Some(
2019                        line.split_once(':')
2020                            .map(|(_, v)| v.trim().to_string())
2021                            .unwrap_or_default(),
2022                    )
2023                } else {
2024                    None
2025                }
2026            });
2027            let _ = request_tx.send((path, auth_header));
2028
2029            let body = r#"{"output":[{"type":"message","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":1,"output_tokens":1}}"#;
2030            let response = format!(
2031                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2032                body.len(),
2033                body
2034            );
2035            let _ = stream.write_all(response.as_bytes());
2036            let _ = stream.flush();
2037        });
2038
2039        let mut config = Config::test_defaults();
2040        config.llm_provider = "openai-codex".into();
2041        config.api_key = "fallback-key".into();
2042        config.model = "gpt-5.3-codex".into();
2043        config.llm_base_url = Some("http://should-be-ignored".into());
2044        config.data_dir = "/tmp".into();
2045        config.working_dir = "/tmp".into();
2046        config.working_dir_isolation = WorkingDirIsolation::Shared;
2047        config.web_enabled = false;
2048        config.web_port = 3900;
2049        let provider = OpenAiProvider::new(&config);
2050        let messages = vec![Message {
2051            role: "user".into(),
2052            content: MessageContent::Text("hi".into()),
2053        }];
2054        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
2055        let resp = LlmProvider::send_message_stream(&provider, "", messages, None, Some(&tx))
2056            .await
2057            .unwrap();
2058        drop(tx);
2059
2060        let (path, auth_header) = request_rx.recv_timeout(Duration::from_secs(2)).unwrap();
2061        server.join().unwrap();
2062        if let Some(prev) = prev_access {
2063            std::env::set_var("OPENAI_CODEX_ACCESS_TOKEN", prev);
2064        } else {
2065            std::env::remove_var("OPENAI_CODEX_ACCESS_TOKEN");
2066        }
2067        if let Some(prev) = prev_codex_home {
2068            std::env::set_var("CODEX_HOME", prev);
2069        } else {
2070            std::env::remove_var("CODEX_HOME");
2071        }
2072        let _ = std::fs::remove_file(codex_home.join("config.toml"));
2073        let _ = std::fs::remove_dir(codex_home);
2074
2075        assert_eq!(path, "/responses");
2076        assert_eq!(auth_header.as_deref(), Some("Bearer oauth-token"));
2077        assert_eq!(resp.stop_reason.as_deref(), Some("end_turn"));
2078        match &resp.content[0] {
2079            ResponseContentBlock::Text { text } => assert_eq!(text, "ok"),
2080            _ => panic!("Expected text block"),
2081        }
2082        assert_eq!(rx.recv().await.as_deref(), Some("ok"));
2083    }
2084
2085    #[tokio::test]
2086    #[allow(clippy::await_holding_lock)]
2087    async fn test_openai_codex_stream_uses_auth_json_openai_api_key_when_oauth_missing() {
2088        let _guard = env_lock();
2089        let prev_access = std::env::var("OPENAI_CODEX_ACCESS_TOKEN").ok();
2090        let prev_codex_home = std::env::var("CODEX_HOME").ok();
2091        std::env::remove_var("OPENAI_CODEX_ACCESS_TOKEN");
2092
2093        let auth_dir = std::env::temp_dir().join(format!(
2094            "microclaw-codex-auth-empty-{}",
2095            std::time::SystemTime::now()
2096                .duration_since(std::time::UNIX_EPOCH)
2097                .unwrap_or_default()
2098                .as_nanos()
2099        ));
2100        std::fs::create_dir_all(&auth_dir).unwrap();
2101        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
2102        let addr = listener.local_addr().unwrap();
2103        std::fs::write(
2104            auth_dir.join("auth.json"),
2105            r#"{"OPENAI_API_KEY":"sk-from-auth-json"}"#,
2106        )
2107        .unwrap();
2108        std::fs::write(
2109            auth_dir.join("config.toml"),
2110            format!(
2111                "model_provider = \"test\"\n\n[model_providers.test]\nbase_url = \"http://{}\"\n",
2112                addr
2113            ),
2114        )
2115        .unwrap();
2116        std::env::set_var("CODEX_HOME", &auth_dir);
2117
2118        let (request_tx, request_rx) = mpsc::channel::<(String, Option<String>)>();
2119
2120        let server = std::thread::spawn(move || {
2121            let (mut stream, _) = listener.accept().unwrap();
2122            stream
2123                .set_read_timeout(Some(Duration::from_secs(2)))
2124                .unwrap();
2125
2126            let mut buf = [0u8; 8192];
2127            let n = stream.read(&mut buf).unwrap_or(0);
2128            let req = String::from_utf8_lossy(&buf[..n]).to_string();
2129            let path = req
2130                .lines()
2131                .next()
2132                .and_then(|line| line.split_whitespace().nth(1))
2133                .unwrap_or("")
2134                .to_string();
2135            let auth_header = req.lines().find_map(|line| {
2136                let lower = line.to_ascii_lowercase();
2137                if lower.starts_with("authorization:") {
2138                    Some(
2139                        line.split_once(':')
2140                            .map(|(_, v)| v.trim().to_string())
2141                            .unwrap_or_default(),
2142                    )
2143                } else {
2144                    None
2145                }
2146            });
2147            let _ = request_tx.send((path, auth_header));
2148
2149            let body = r#"{"output":[{"type":"message","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":1,"output_tokens":1}}"#;
2150            let response = format!(
2151                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2152                body.len(),
2153                body
2154            );
2155            let _ = stream.write_all(response.as_bytes());
2156            let _ = stream.flush();
2157        });
2158
2159        let mut config = Config::test_defaults();
2160        config.llm_provider = "openai-codex".into();
2161        config.api_key = "should-be-ignored".into();
2162        config.model = "gpt-5.3-codex".into();
2163        config.llm_base_url = Some("http://should-be-ignored".into());
2164        config.data_dir = "/tmp".into();
2165        config.working_dir = "/tmp".into();
2166        config.working_dir_isolation = WorkingDirIsolation::Shared;
2167        config.web_enabled = false;
2168        config.web_port = 3900;
2169        let provider = OpenAiProvider::new(&config);
2170        let messages = vec![Message {
2171            role: "user".into(),
2172            content: MessageContent::Text("hi".into()),
2173        }];
2174        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
2175        let resp = LlmProvider::send_message_stream(&provider, "", messages, None, Some(&tx))
2176            .await
2177            .unwrap();
2178        drop(tx);
2179
2180        let (path, auth_header) = request_rx.recv_timeout(Duration::from_secs(2)).unwrap();
2181        server.join().unwrap();
2182        if let Some(prev) = prev_codex_home {
2183            std::env::set_var("CODEX_HOME", prev);
2184        } else {
2185            std::env::remove_var("CODEX_HOME");
2186        }
2187        if let Some(prev) = prev_access {
2188            std::env::set_var("OPENAI_CODEX_ACCESS_TOKEN", prev);
2189        } else {
2190            std::env::remove_var("OPENAI_CODEX_ACCESS_TOKEN");
2191        }
2192        let _ = std::fs::remove_file(auth_dir.join("auth.json"));
2193        let _ = std::fs::remove_file(auth_dir.join("config.toml"));
2194        let _ = std::fs::remove_dir(auth_dir);
2195
2196        assert_eq!(path, "/responses");
2197        assert_eq!(auth_header.as_deref(), Some("Bearer sk-from-auth-json"));
2198        assert_eq!(resp.stop_reason.as_deref(), Some("end_turn"));
2199        match &resp.content[0] {
2200            ResponseContentBlock::Text { text } => assert_eq!(text, "ok"),
2201            _ => panic!("Expected text block"),
2202        }
2203        assert_eq!(rx.recv().await.as_deref(), Some("ok"));
2204    }
2205
2206    #[test]
2207    fn test_resolve_openai_compat_base_defaults_openai() {
2208        let base = resolve_openai_compat_base("openai", "");
2209        assert_eq!(base, "https://api.openai.com/v1");
2210    }
2211
2212    #[test]
2213    fn test_resolve_openai_compat_base_defaults_openrouter() {
2214        let base = resolve_openai_compat_base("openrouter", "");
2215        assert_eq!(base, "https://openrouter.ai/api/v1");
2216    }
2217
2218    #[test]
2219    fn test_resolve_openai_compat_base_defaults_deepseek() {
2220        let base = resolve_openai_compat_base("deepseek", "");
2221        assert_eq!(base, "https://api.deepseek.com/v1");
2222    }
2223
2224    #[test]
2225    fn test_resolve_openai_compat_base_defaults_ollama() {
2226        let base = resolve_openai_compat_base("ollama", "");
2227        assert_eq!(base, "http://127.0.0.1:11434/v1");
2228    }
2229
2230    #[test]
2231    fn test_resolve_openai_compat_base_defaults_google() {
2232        let base = resolve_openai_compat_base("google", "");
2233        assert_eq!(
2234            base,
2235            "https://generativelanguage.googleapis.com/v1beta/openai"
2236        );
2237    }
2238
2239    #[test]
2240    fn test_resolve_openai_compat_base_unknown_provider_falls_back_to_openai() {
2241        let base = resolve_openai_compat_base("some-unknown-provider", "");
2242        assert_eq!(base, "https://api.openai.com/v1");
2243    }
2244
2245    #[test]
2246    fn test_resolve_openai_compat_base_custom_overrides_provider_default() {
2247        let base = resolve_openai_compat_base("openrouter", "https://custom.example.com/v1");
2248        assert_eq!(base, "https://custom.example.com/v1");
2249    }
2250
2251    #[test]
2252    fn test_resolve_openai_compat_base_defaults_openai_codex() {
2253        let _guard = env_lock();
2254        let prev_codex_home = std::env::var("CODEX_HOME").ok();
2255        let temp = std::env::temp_dir().join(format!(
2256            "microclaw-llm-codex-base-default-{}",
2257            std::time::SystemTime::now()
2258                .duration_since(std::time::UNIX_EPOCH)
2259                .unwrap_or_default()
2260                .as_nanos()
2261        ));
2262        std::fs::create_dir_all(&temp).unwrap();
2263        std::env::set_var("CODEX_HOME", &temp);
2264
2265        let base = resolve_openai_compat_base("openai-codex", "");
2266        assert_eq!(base, "https://chatgpt.com/backend-api/codex");
2267
2268        if let Some(prev) = prev_codex_home {
2269            std::env::set_var("CODEX_HOME", prev);
2270        } else {
2271            std::env::remove_var("CODEX_HOME");
2272        }
2273        let _ = std::fs::remove_dir(temp);
2274    }
2275
2276    #[test]
2277    fn test_resolve_openai_compat_base_codex_uses_codex_config_toml_base() {
2278        let _guard = env_lock();
2279        let prev_codex_home = std::env::var("CODEX_HOME").ok();
2280        let temp = std::env::temp_dir().join(format!(
2281            "microclaw-llm-codex-base-file-{}",
2282            std::time::SystemTime::now()
2283                .duration_since(std::time::UNIX_EPOCH)
2284                .unwrap_or_default()
2285                .as_nanos()
2286        ));
2287        std::fs::create_dir_all(&temp).unwrap();
2288        std::fs::write(
2289            temp.join("config.toml"),
2290            "model_provider = \"tabcode\"\n\n[model_providers.tabcode]\nbase_url = \"https://api.tabcode.cc/openai\"\n",
2291        )
2292        .unwrap();
2293        std::env::set_var("CODEX_HOME", &temp);
2294
2295        let base = resolve_openai_compat_base("openai-codex", "https://ignored.example.com");
2296        assert_eq!(base, "https://api.tabcode.cc/openai");
2297
2298        if let Some(prev) = prev_codex_home {
2299            std::env::set_var("CODEX_HOME", prev);
2300        } else {
2301            std::env::remove_var("CODEX_HOME");
2302        }
2303        let _ = std::fs::remove_file(temp.join("config.toml"));
2304        let _ = std::fs::remove_dir(temp);
2305    }
2306
2307    #[test]
2308    fn test_parse_openai_codex_response_payload_json() {
2309        let body = r#"{
2310          "output":[{"type":"message","content":[{"type":"output_text","text":"Hello"}]}],
2311          "usage":{"input_tokens":12,"output_tokens":34}
2312        }"#;
2313        let parsed = parse_openai_codex_response_payload(body).unwrap();
2314        let translated = translate_oai_responses_response(parsed);
2315        assert_eq!(translated.stop_reason.as_deref(), Some("end_turn"));
2316        match &translated.content[0] {
2317            ResponseContentBlock::Text { text } => assert_eq!(text, "Hello"),
2318            _ => panic!("Expected text block"),
2319        }
2320    }
2321
2322    #[test]
2323    fn test_parse_openai_codex_response_payload_sse_response_done() {
2324        let body = r#"event: response.created
2325data: {"type":"response.created","response":{"output":[]}}
2326
2327event: response.done
2328data: {"type":"response.done","response":{"output":[{"type":"message","content":[{"type":"output_text","text":"From SSE"}]}],"usage":{"input_tokens":1,"output_tokens":2}}}
2329
2330data: [DONE]
2331"#;
2332        let parsed = parse_openai_codex_response_payload(body).unwrap();
2333        let translated = translate_oai_responses_response(parsed);
2334        assert_eq!(translated.stop_reason.as_deref(), Some("end_turn"));
2335        match &translated.content[0] {
2336            ResponseContentBlock::Text { text } => assert_eq!(text, "From SSE"),
2337            _ => panic!("Expected text block"),
2338        }
2339    }
2340
2341    #[test]
2342    fn test_parse_openai_codex_response_payload_recovers_streamed_text() {
2343        let body = r#"data: {"type":"response.output_text.delta","delta":"Hello "}
2344
2345data: {"type":"response.output_text.delta","delta":"from stream"}
2346
2347data: {"type":"response.done","response":{"output":[],"usage":{"input_tokens":1,"output_tokens":2}}}
2348
2349data: [DONE]
2350"#;
2351        let parsed = parse_openai_codex_response_payload(body).unwrap();
2352        let translated = translate_oai_responses_response(parsed);
2353        match &translated.content[0] {
2354            ResponseContentBlock::Text { text } => assert_eq!(text, "Hello from stream"),
2355            _ => panic!("Expected recovered text block"),
2356        }
2357    }
2358
2359    #[test]
2360    fn test_parse_openai_codex_response_payload_accepts_delta_only_stream() {
2361        let body = r#"data: {"type":"response.output_text.delta","delta":"Visible"}
2362
2363data: [DONE]
2364"#;
2365        let parsed = parse_openai_codex_response_payload(body).unwrap();
2366        let translated = translate_oai_responses_response(parsed);
2367        match &translated.content[0] {
2368            ResponseContentBlock::Text { text } => assert_eq!(text, "Visible"),
2369            _ => panic!("Expected recovered text block"),
2370        }
2371    }
2372
2373    #[test]
2374    fn test_parse_openai_codex_response_payload_recovers_streamed_function_call() {
2375        let body = r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"read_file","arguments":"{\"path\":\"TASK.md\"}"}}
2376
2377data: {"type":"response.done","response":{"output":[],"usage":{"input_tokens":10,"output_tokens":5}}}
2378
2379data: [DONE]
2380"#;
2381        let parsed = parse_openai_codex_response_payload(body).unwrap();
2382        let translated = translate_oai_responses_response(parsed);
2383        assert_eq!(translated.stop_reason.as_deref(), Some("tool_use"));
2384        match &translated.content[0] {
2385            ResponseContentBlock::ToolUse {
2386                id, name, input, ..
2387            } => {
2388                assert_eq!(id, "call_1");
2389                assert_eq!(name, "read_file");
2390                assert_eq!(input, &serde_json::json!({"path": "TASK.md"}));
2391            }
2392            _ => panic!("Expected recovered tool call"),
2393        }
2394    }
2395
2396    #[test]
2397    fn test_parse_openai_codex_response_payload_accepts_function_call_only_stream() {
2398        let body = r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"write_file","arguments":"{\"path\":\"RESULT.md\",\"content\":\"done\"}"}}
2399
2400data: [DONE]
2401"#;
2402        let parsed = parse_openai_codex_response_payload(body).unwrap();
2403        let translated = translate_oai_responses_response(parsed);
2404        assert_eq!(translated.stop_reason.as_deref(), Some("tool_use"));
2405        assert!(matches!(
2406            &translated.content[0],
2407            ResponseContentBlock::ToolUse { name, .. } if name == "write_file"
2408        ));
2409    }
2410
2411    #[test]
2412    fn test_parse_openai_codex_response_payload_uses_sse_event_name() {
2413        let body = r#"event: response.output_item.done
2414data: {"output_index":0,"item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"read_file","arguments":"{\"path\":\"TASK.md\"}"}}
2415
2416event: response.completed
2417data: {"response":{"output":[],"usage":{"input_tokens":10,"output_tokens":5}}}
2418
2419data: [DONE]
2420"#;
2421        let parsed = parse_openai_codex_response_payload(body).unwrap();
2422        let translated = translate_oai_responses_response(parsed);
2423        assert_eq!(translated.stop_reason.as_deref(), Some("tool_use"));
2424        assert!(matches!(
2425            &translated.content[0],
2426            ResponseContentBlock::ToolUse { name, .. } if name == "read_file"
2427        ));
2428    }
2429}