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" => {
271 obj.insert("enable_thinking".to_string(), json!(true));
272 }
273 "minimax" => {
275 obj.insert("reasoning_split".to_string(), json!(true));
276 }
277 "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#[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 #[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 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#[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}