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