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