1use super::translate;
7use super::types::{
8 ChatContentPart, ChatMessage, OaiEvent, ProviderConfig, StreamOptions, ToolCall,
9};
10use super::wire::StreamDecoder;
11use crate::runtime::trace::openai as tr;
12use crate::runtime::types::StreamEvent;
13use futures::StreamExt;
14use serde_json::{json, Value};
15use tokio::sync::mpsc;
16
17fn capture_oai_trace_signals(
21 events: &[OaiEvent],
22 attempt: &mut tr::StreamAttempt,
23 usage: &mut Option<crate::runtime::trace::UsageMeta>,
24) {
25 for ev in events {
26 if !matches!(ev, OaiEvent::Warning(_)) {
27 attempt.mark_first_model_event();
28 }
29 if let OaiEvent::Usage {
30 prompt_tokens,
31 completion_tokens,
32 cached_tokens,
33 } = ev
34 {
35 *usage = Some(tr::provider_usage(
36 u64::from(*prompt_tokens),
37 u64::from(*completion_tokens),
38 u64::from(*cached_tokens),
39 ));
40 }
41 }
42}
43
44pub(crate) const CODEX_PERSISTENT_RETRIES: u32 = 10;
52
53const RETRY_BACKOFF_EXP_CAP: u32 = 6;
57
58pub(crate) fn codex_retry_budget(configured_retries: u32) -> u32 {
63 configured_retries.max(CODEX_PERSISTENT_RETRIES)
64}
65
66fn retry_delay(attempt: u32) -> std::time::Duration {
68 std::time::Duration::from_millis(
69 1000 * 2u64.pow(attempt.saturating_sub(1).min(RETRY_BACKOFF_EXP_CAP)),
70 )
71}
72
73async fn wait_stream_retry(
76 tx: &mpsc::UnboundedSender<StreamEvent>,
77 cancel: &tokio_util::sync::CancellationToken,
78 retry: u32,
79 max_retries: u32,
80 suppress_stream_deltas: bool,
81) -> Result<(), super::net::BoxedProviderError> {
82 use crate::{LlmEvent, SessionEvent};
83 if cancel.is_cancelled() {
84 return Err("request canceled".into());
85 }
86 if !suppress_stream_deltas {
87 let _ = tx.send(StreamEvent::Llm(LlmEvent::ResponseReset));
88 }
89 let _ = tx.send(StreamEvent::Session(SessionEvent::Notice(format!(
90 "Response incomplete — retrying response ({retry}/{max_retries}); incomplete output discarded, no tools replayed."
91 ))));
92 tokio::select! {
93 biased;
94 _ = cancel.cancelled() => Err("request canceled".into()),
95 _ = tokio::time::sleep(retry_delay(retry)) => Ok(()),
96 }
97}
98
99pub(crate) const STREAM_INTERRUPTED: &str = "openai request failed: connection interrupted before response completed (stream retry budget exhausted)";
100
101async fn send_with_retries(
121 label: &str,
122 url: &str,
123 build: impl Fn() -> reqwest::RequestBuilder,
124 cancel: &tokio_util::sync::CancellationToken,
125 max_retries: u32,
126 retries_used: &mut u32,
127 trace_attempt: &mut tr::StreamAttempt,
128) -> Result<reqwest::Response, Box<dyn std::error::Error + Send + Sync>> {
129 loop {
130 if cancel.is_cancelled() {
131 return Err("request canceled".into());
132 }
133 let sent = tokio::select! {
134 biased;
135 _ = cancel.cancelled() => { trace_attempt.finish_canceled(None, None); return Err("request canceled".into()); }
136 sent = build().send() => sent,
137 };
138 match sent {
139 Ok(resp) if resp.status().is_success() => {
140 trace_attempt.mark_headers();
141 return Ok(resp);
142 }
143 Ok(resp) => {
144 trace_attempt.mark_headers();
148 let status = resp.status();
149 let trace_rid = tr::provider_request_id_from_headers(resp.headers());
151 let retryable =
152 status.as_u16() == 408 || status.as_u16() == 429 || status.is_server_error();
153 drop(resp);
159 let code = format!("http_{}", status.as_u16());
160 if !retryable || *retries_used >= max_retries {
161 trace_attempt.finish_failed(&code, Some(status.as_u16()), trace_rid);
162 return Err(format!("{label} request failed: {status}").into());
163 }
164 *retries_used += 1;
165 let delay = retry_delay(*retries_used);
166 trace_attempt.attempt_failed(
167 tr::retry_class_for_status(status.as_u16()),
168 delay,
169 Some(status.as_u16()),
170 trace_rid,
171 &code,
172 );
173 tracing::warn!(
174 "{label} API retry {retries_used}/{max_retries} after {delay:?}: {status}"
175 );
176 tokio::select! {
177 _ = tokio::time::sleep(delay) => {}
178 _ = cancel.cancelled() => {
179 trace_attempt.finish_canceled(None, None);
180 return Err("request canceled".into());
181 }
182 }
183 trace_attempt.restart_clock();
185 }
186 Err(e) => {
187 let localhost_refusal = e.is_connect() && url.contains("localhost");
188 let transient = e.is_timeout() || e.is_connect() || e.is_request();
189 if localhost_refusal || !transient || *retries_used >= max_retries {
190 tracing::warn!(
191 "{label} request failed (no retry): {}",
192 crate::core::error::error_chain_string(&e)
193 );
194 trace_attempt.finish_failed("transport_error", None, None);
195 return Err(e.into());
196 }
197 *retries_used += 1;
198 let delay = retry_delay(*retries_used);
199 trace_attempt.attempt_failed(
200 if e.is_timeout() {
201 crate::runtime::trace::RetryClass::Timeout
202 } else {
203 crate::runtime::trace::RetryClass::Other
204 },
205 delay,
206 None,
207 None,
208 "transport_error",
209 );
210 tracing::warn!(
211 "{label} transport retry {retries_used}/{max_retries} after {delay:?}: {}",
212 crate::core::error::error_chain_string(&e)
213 );
214 tokio::select! {
215 _ = tokio::time::sleep(delay) => {}
216 _ = cancel.cancelled() => {
217 trace_attempt.finish_canceled(None, None);
218 return Err("request canceled".into());
219 }
220 }
221 trace_attempt.restart_clock();
223 }
224 }
225 }
226}
227
228#[allow(clippy::too_many_arguments)]
234pub(crate) async fn call_oai_stream_inner(
235 cfg: &ProviderConfig,
236 broker: &std::sync::Arc<dyn crate::auth::CredentialBroker>,
237 tools_schema: &[Value],
238 system_prompt: &Option<String>,
239 messages: &[crate::SharedMessage],
240 tx: &mpsc::UnboundedSender<StreamEvent>,
241 temperature: Option<f32>,
242 max_tokens: Option<u32>,
243 thinking_budget: u32,
244 reasoning_level: agent_core::reasoning::ReasoningLevel,
245 cancel: &tokio_util::sync::CancellationToken,
246 trace: &crate::runtime::trace::TraceContext,
247 exact_wire_bytes: bool,
248 suppress_stream_deltas: bool,
249 max_retries: u32,
250) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
251 let (oai_tools, name_map) = translate::tools_to_oai(tools_schema);
252 let oai_messages = translate::messages_to_oai(messages, system_prompt, &name_map);
253 let tools_opt = if oai_tools.is_empty() {
254 None
255 } else {
256 Some(oai_tools)
257 };
258
259 let stream_options = if cfg.base_url.contains("googleapis.com") {
261 None
262 } else {
263 Some(StreamOptions {
264 include_usage: true,
265 })
266 };
267
268 let mut body = serde_json::Map::new();
269 body.insert("model".to_string(), json!(cfg.model.clone()));
270 body.insert("messages".to_string(), serde_json::to_value(oai_messages)?);
271 body.insert("stream".to_string(), json!(true));
272 if cfg.base_url.contains("api.openai.com") {
279 body.insert("store".to_string(), json!(false));
280 }
281 if let Some(stream_options) = stream_options {
282 body.insert(
283 "stream_options".to_string(),
284 serde_json::to_value(stream_options)?,
285 );
286 }
287 if let Some(max_tokens) = max_tokens {
288 body.insert("max_tokens".to_string(), json!(max_tokens));
289 }
290 if let Some(temperature) = temperature {
291 body.insert("temperature".to_string(), json!(temperature));
292 }
293 if let Some(tools) = tools_opt {
294 body.insert("tools".to_string(), serde_json::to_value(tools)?);
295 }
296 super::reasoning::apply_openai_reasoning_params(
297 &mut body,
298 super::reasoning::provider_for_key(&cfg.provider),
299 &cfg.model,
300 thinking_budget,
301 reasoning_level,
302 );
303 let body = Value::Object(body);
304
305 tracing::debug!(provider=%cfg.provider, model=%cfg.model, "openai stream request via broker proxy");
306
307 let (proxy_request, body_bytes) = crate::auth::ProxyRequest::post_json_exact(
313 cfg.provider.clone(),
314 "/chat/completions",
315 body,
316 true,
317 )?;
318 let tracer = tr::begin_openai_tracer(
322 trace,
323 &cfg.provider,
324 &cfg.model,
325 if exact_wire_bytes {
326 crate::runtime::trace::TransportKind::OpenAiChatCompletions
327 } else {
328 crate::runtime::trace::TransportKind::CloudProxy
329 },
330 &format!("{}/chat/completions", cfg.base_url.trim_end_matches('/')),
331 exact_wire_bytes.then_some(body_bytes.as_ref()),
332 messages,
333 system_prompt.as_deref(),
334 tools_schema,
335 tr::renamed_tool_losses(&name_map),
336 )
337 .await;
338 if let Some(tracer) = &tracer {
344 tr::capture_request_content(trace, tracer.request_id(), body_bytes.as_ref(), messages);
345 }
346 let mut attempt = tr::StreamAttempt::new(tracer);
347
348 let mut stream_retry = 0u32;
349 loop {
350 if cancel.is_cancelled() {
351 return Err("request canceled".into());
352 }
353 let stream = crate::runtime::api::await_or_cancel(
356 cancel,
357 broker.proxy_stream(proxy_request.clone()),
358 )
359 .await
360 .map_err(|_| "request canceled")?
361 .map_err(|e| {
364 format!(
365 "openai request failed: {}",
366 super::net::redact_provider_proxy_error(&e.to_string())
367 )
368 });
369 let mut stream = match stream {
370 Ok(stream) => {
371 attempt.mark_headers();
372 stream
373 }
374 Err(msg) => {
375 let status = tr::broker_error_status(&msg);
378 let code =
379 status.map_or_else(|| "broker_error".to_string(), |s| format!("http_{s}"));
380 attempt.finish_failed(&code, status, None);
381 return Err(msg.into());
382 }
383 };
384
385 if !suppress_stream_deltas {
386 let _ = tx.send(StreamEvent::Llm(crate::LlmEvent::ResponseStart));
387 }
388 let mut interrupted = false;
389 let mut saw_done = false;
390 let mut decoder = StreamDecoder::new();
391 let mut accumulated_text = String::new();
392 let mut tool_use_blocks: Vec<Value> = Vec::new();
393 let mut buf = bytes::BytesMut::with_capacity(8 * 1024);
394 let mut sink: Vec<OaiEvent> = Vec::with_capacity(4);
395 let mut trace_usage: Option<crate::runtime::trace::UsageMeta> = None;
396
397 while let Some(chunk) = tokio::select! {
398 biased;
399 _ = cancel.cancelled() => {
400 attempt.finish_canceled(None, trace_usage);
401 return Err("request canceled".into());
402 }
403 chunk = stream.next() => chunk,
404 } {
405 let chunk = match chunk {
406 Ok(chunk) => {
407 attempt.mark_first_byte();
408 chunk
409 }
410 Err(e) => {
411 if !matches!(e, crate::auth::BrokerError::Transport(_)) {
412 attempt.finish_failed("broker_error", None, None);
413 return Err(e.into());
414 }
415 interrupted = !saw_done && decoder.finish_reason.is_none();
416 break;
417 }
418 };
419 buf.extend_from_slice(&chunk);
420
421 while let Some(nl) = memchr::memchr(b'\n', &buf) {
423 let line_bytes = buf.split_to(nl + 1); let line = std::str::from_utf8(&line_bytes[..nl]).unwrap_or("");
425
426 sink.clear();
427 decoder.push_line(line, &mut sink);
428 saw_done |= sink.iter().any(|e| matches!(e, OaiEvent::Done));
429 capture_oai_trace_signals(&sink, &mut attempt, &mut trace_usage);
430 handle_events(
431 &sink,
432 tx,
433 &mut accumulated_text,
434 &mut tool_use_blocks,
435 &name_map,
436 suppress_stream_deltas,
437 );
438 }
439 }
440
441 if !interrupted && !buf.is_empty() {
443 let line = std::str::from_utf8(&buf).unwrap_or("");
444 sink.clear();
445 decoder.push_line(line, &mut sink);
446 saw_done |= sink.iter().any(|e| matches!(e, OaiEvent::Done));
447 capture_oai_trace_signals(&sink, &mut attempt, &mut trace_usage);
448 handle_events(
449 &sink,
450 tx,
451 &mut accumulated_text,
452 &mut tool_use_blocks,
453 &name_map,
454 suppress_stream_deltas,
455 );
456 }
457 if interrupted || (!saw_done && decoder.finish_reason.is_none()) {
458 if stream_retry >= max_retries {
459 attempt.finish_failed("stream_error", None, None);
460 return Err(STREAM_INTERRUPTED.into());
461 }
462 stream_retry += 1;
463 attempt.attempt_failed(
464 crate::runtime::trace::RetryClass::Network,
465 retry_delay(stream_retry),
466 None,
467 None,
468 "stream_error",
469 );
470 wait_stream_retry(
471 tx,
472 cancel,
473 stream_retry,
474 max_retries,
475 suppress_stream_deltas,
476 )
477 .await
478 .inspect_err(|_| {
479 attempt.finish_canceled(None, None);
480 })?;
481 attempt.restart_clock();
482 continue;
483 }
484 sink.clear();
485 decoder.finish(&mut sink);
486 capture_oai_trace_signals(&sink, &mut attempt, &mut trace_usage);
487 handle_events(
488 &sink,
489 tx,
490 &mut accumulated_text,
491 &mut tool_use_blocks,
492 &name_map,
493 suppress_stream_deltas,
494 );
495
496 let stop_reason = decoder
499 .finish_reason
500 .as_deref()
501 .map(tr::stop_reason_from_finish_reason);
502 attempt.finish_success(None, None, stop_reason, trace_usage);
504
505 let mut content: Vec<Value> = Vec::new();
507 if !accumulated_text.is_empty() {
508 content.push(json!({"type": "text", "text": accumulated_text}));
509 }
510 content.extend(tool_use_blocks);
511
512 return Ok(json!({
513 "role": "assistant",
514 "content": content,
515 }));
516 }
517}
518
519#[allow(clippy::too_many_arguments)]
520pub(crate) async fn call_codex_stream_inner(
521 cfg: &ProviderConfig,
522 client: &reqwest::Client,
523 broker: &std::sync::Arc<dyn crate::auth::CredentialBroker>,
524 tools_schema: &[Value],
525 system_prompt: &Option<String>,
526 messages: &[crate::SharedMessage],
527 tx: &mpsc::UnboundedSender<StreamEvent>,
528 temperature: Option<f32>,
529 max_tokens: Option<u32>,
530 reasoning_level: agent_core::reasoning::ReasoningLevel,
531 codex_request_role: crate::runtime::openai::catalog::CodexRequestRole,
532 cancel: &tokio_util::sync::CancellationToken,
533 max_retries: u32,
534 trace: &crate::runtime::trace::TraceContext,
535) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
536 use crate::runtime::openai::catalog::plan_codex_execution;
539 let qualified_model = format!("{}/{}", cfg.provider, cfg.model);
540 let plan =
541 match plan_codex_execution(&qualified_model, reasoning_level, codex_request_role, None) {
542 Ok(plan) => plan,
543 Err(error) => {
544 tracing::debug!(
545 event = "codex_mode_plan",
546 qualified_model = %qualified_model,
547 requested_level = %reasoning_level,
548 runtime_role = codex_request_role.as_str(),
549 decision = "deny",
550 deny_code = error.code().as_str(),
551 network_attempted = false,
552 "Codex execution plan denied"
553 );
554 return Err(Box::new(error));
555 }
556 };
557 if plan.automatic_delegation() && !codex_has_required_delegation_tools(tools_schema) {
558 tracing::debug!(
559 event = "codex_mode_plan",
560 qualified_model = %qualified_model,
561 requested_level = %reasoning_level,
562 runtime_role = codex_request_role.as_str(),
563 decision = "deny",
564 deny_code = "ultra_requires_subagent_tools",
565 network_attempted = false,
566 "Codex execution plan denied"
567 );
568 return Err(
569 "Ultra requires subagent_start, subagent_status, and subagent_collect tools".into(),
570 );
571 }
572 tracing::debug!(
573 event = "codex_mode_plan",
574 qualified_model = %plan.qualified_model,
575 requested_level = %plan.selected_level,
576 execution_mode = plan.mode.as_str(),
577 wire_effort = plan.wire_effort_label(),
578 capability_source = plan.capability_source.map_or("none", |source| source.as_str()),
579 multi_agent_version = plan.multi_agent_version_label(),
580 runtime_role = plan.request_role.as_str(),
581 multi_agent_mode = plan.multi_agent_mode_label(),
582 automatic_delegation = plan.automatic_delegation(),
583 decision = "allow",
584 network_attempted = false,
585 "Codex execution plan allowed"
586 );
587 let access = crate::runtime::api::await_or_cancel(
591 cancel,
592 broker.access_token(crate::auth::OAuthProviderId::OpenAiCodex),
593 )
594 .await
595 .map_err(|_| "request canceled")?
596 .map_err(|e| e.to_string())?
597 .token;
598 let account_id = crate::auth::extract_codex_account_id(&access)
600 .ok_or("Failed to extract ChatGPT account id from Codex token — run `synaps login --provider openai-codex`")?;
601
602 let (tools, name_map) = translate::tools_to_responses(tools_schema);
603 let oai_messages = translate::messages_to_oai(messages, &None, &name_map);
608
609 let instructions = codex_instructions(system_prompt);
612 let mut input_items = codex_input_messages(oai_messages);
613 let prompt_cache_key = codex_prompt_cache_key(&instructions, input_items.first());
616 insert_codex_multi_agent_mode(&mut input_items, &plan);
617 let input = serde_json::Value::Array(input_items);
618 let body = build_codex_body(
619 &cfg.model,
620 &plan,
621 input,
622 instructions,
623 tools,
624 temperature,
625 max_tokens,
626 &prompt_cache_key,
627 );
628
629 let url = format!(
630 "{}/codex/responses",
631 cfg.base_url
632 .trim_end_matches('/')
633 .trim_end_matches("/codex")
634 );
635 tracing::debug!(url=%url, model=%cfg.model, "codex stream request");
636
637 let body_bytes = bytes::Bytes::from(serde_json::to_vec(&body)?);
641 let tracer = tr::begin_openai_tracer(
643 trace,
644 &cfg.provider,
645 &cfg.model,
646 crate::runtime::trace::TransportKind::OpenAiResponses,
647 &url,
648 Some(body_bytes.as_ref()),
649 messages,
650 system_prompt.as_deref(),
651 tools_schema,
652 tr::renamed_tool_losses(&name_map),
653 )
654 .await;
655 if let Some(tracer) = &tracer {
661 tr::capture_request_content(trace, tracer.request_id(), body_bytes.as_ref(), messages);
662 }
663 let mut attempt = tr::StreamAttempt::new(tracer);
664 let mut stream_retry = 0u32;
665
666 loop {
667 if cancel.is_cancelled() {
668 return Err("request canceled".into());
669 }
670 let resp = send_with_retries(
671 "codex",
672 &url,
673 || {
674 client
675 .post(&url)
676 .bearer_auth(&access)
677 .header("chatgpt-account-id", account_id.as_str())
678 .header("originator", "synaps")
679 .header("OpenAI-Beta", "responses=experimental")
680 .header("session-id", prompt_cache_key.as_str())
686 .header("thread-id", prompt_cache_key.as_str())
687 .header("content-type", "application/json")
688 .header("accept", "text/event-stream")
689 .body(body_bytes.clone())
690 },
691 cancel,
692 max_retries,
693 &mut stream_retry,
694 &mut attempt,
695 )
696 .await?;
697 let http_status = Some(resp.status().as_u16());
699 let trace_rid = tr::provider_request_id_from_headers(resp.headers());
700
701 let _ = tx.send(StreamEvent::Llm(crate::LlmEvent::ResponseStart));
702 let mut interrupted = false;
703 let mut accumulated_text = String::new();
704 let mut parser = CodexSseDecoder::default();
705 let mut buf = bytes::BytesMut::with_capacity(8 * 1024);
706 let mut stream = resp.bytes_stream();
707
708 while let Some(chunk) = tokio::select! {
709 biased;
710 _ = cancel.cancelled() => {
711 attempt.finish_canceled(http_status, parser.trace_usage());
712 return Err("request canceled".into());
713 }
714 chunk = stream.next() => chunk,
715 } {
716 let chunk = match chunk {
717 Ok(chunk) => {
718 attempt.mark_first_byte();
719 chunk
720 }
721 Err(_) => {
722 interrupted = !parser.terminal_success && parser.terminal_failure.is_none();
723 break;
724 }
725 };
726 buf.extend_from_slice(&chunk);
727 while let Some(nl) = memchr::memchr(b'\n', &buf) {
728 let line_bytes = buf.split_to(nl + 1);
729 let line = std::str::from_utf8(&line_bytes[..nl]).unwrap_or("");
730 parser.push_line(line, tx, &mut accumulated_text);
731 }
732 if parser.saw_model_event {
733 attempt.mark_first_model_event();
734 }
735 }
736 if !interrupted && !buf.is_empty() {
737 let line = std::str::from_utf8(&buf).unwrap_or("");
738 parser.push_line(line, tx, &mut accumulated_text);
739 }
740 if interrupted || (!parser.terminal_success && parser.terminal_failure.is_none()) {
741 if stream_retry >= max_retries {
742 attempt.finish_failed("stream_error", http_status, trace_rid);
743 return Err(format!("Codex{RESPONSES_MISSING_TERMINAL_SUFFIX}").into());
744 }
745 stream_retry += 1;
746 attempt.attempt_failed(
747 crate::runtime::trace::RetryClass::Network,
748 retry_delay(stream_retry),
749 http_status,
750 trace_rid,
751 "stream_error",
752 );
753 wait_stream_retry(tx, cancel, stream_retry, max_retries, false)
754 .await
755 .inspect_err(|_| {
756 attempt.finish_canceled(None, None);
757 })?;
758 attempt.restart_clock();
759 continue;
760 }
761 parser.finish();
762 if parser.saw_model_event {
766 attempt.mark_first_model_event();
767 }
768 if let Err(failure) = parser.terminal_result() {
769 if failure.code == "responses_empty" && stream_retry < max_retries {
770 stream_retry += 1;
771 let delay = retry_delay(stream_retry);
772 attempt.attempt_failed(
773 crate::runtime::trace::RetryClass::Other,
774 delay,
775 http_status,
776 trace_rid,
777 failure.code,
778 );
779 tracing::warn!(
780 "codex stream retry {stream_retry}/{max_retries} after {delay:?}: completed without usable output"
781 );
782 wait_stream_retry(tx, cancel, stream_retry, max_retries, false)
783 .await
784 .inspect_err(|_| {
785 attempt.finish_canceled(None, None);
786 })?;
787 attempt.restart_clock();
788 continue;
789 }
790 attempt.finish_failed(failure.code, http_status, trace_rid);
791 return Err(failure.message.into());
792 }
793 attempt.finish_success(http_status, trace_rid, None, parser.trace_usage());
794
795 let mut content: Vec<Value> = Vec::new();
796 if !accumulated_text.is_empty() {
797 content.push(json!({"type": "text", "text": accumulated_text}));
798 }
799 content.extend(translate::tool_calls_to_content_blocks(
800 &parser.completed_tools,
801 &name_map,
802 ));
803
804 return Ok(json!({
805 "role": "assistant",
806 "content": content,
807 }));
808 }
809}
810
811pub(crate) fn codex_prompt_cache_key(instructions: &str, first_item: Option<&Value>) -> String {
825 use sha2::{Digest, Sha256};
826 let mut hasher = Sha256::new();
827 hasher.update(instructions.as_bytes());
828 if let Some(item) = first_item {
829 hasher.update(item.to_string().as_bytes());
830 }
831 format!("synaps-{:x}", hasher.finalize())
832}
833
834#[allow(clippy::too_many_arguments)] pub(crate) fn build_codex_body(
851 model: &str,
852 plan: &crate::runtime::openai::catalog::CodexExecutionPlan,
853 input: serde_json::Value,
854 instructions: String,
855 tools: Vec<serde_json::Value>,
856 temperature: Option<f32>,
857 max_tokens: Option<u32>,
858 prompt_cache_key: &str,
859) -> serde_json::Value {
860 let mut body = json!({
861 "model": model,
862 "store": false,
863 "stream": true,
864 "instructions": instructions,
865 "input": input,
866 "tool_choice": "auto",
867 "parallel_tool_calls": true,
868 "include": ["reasoning.encrypted_content"],
869 "text": { "verbosity": "medium" },
870 "prompt_cache_key": prompt_cache_key,
871 });
872 if let Some(effort) = plan.wire_effort {
873 body["reasoning"] = json!({ "effort": effort.as_str() });
874 }
875 if !tools.is_empty() {
876 body["tools"] = serde_json::Value::Array(tools);
877 }
878 if let Some(temp) = temperature {
879 body["temperature"] = json!(temp);
880 }
881 if let Some(max) = max_tokens {
882 body["max_output_tokens"] = json!(max);
883 }
884 body
885}
886
887const CODEX_MULTI_AGENT_MODE_OPEN: &str = "<multi_agent_mode>";
888const CODEX_MULTI_AGENT_MODE_CLOSE: &str = "</multi_agent_mode>";
889const CODEX_EXPLICIT_REQUEST_ONLY_TEXT: &str = "Do not spawn sub-agents unless the user or applicable AGENTS.md/skill instructions explicitly ask for sub-agents, delegation, or parallel agent work.";
890const CODEX_PROACTIVE_MULTI_AGENT_TEXT: &str = "Proactive multi-agent delegation is active. Any earlier instruction requiring an explicit user request before spawning sub-agents no longer applies. Use sub-agents when parallel work would materially improve speed or quality. This mode remains active until a later multi-agent mode developer message changes it.";
891
892fn codex_multi_agent_mode_item(
893 plan: &crate::runtime::openai::catalog::CodexExecutionPlan,
894) -> Option<Value> {
895 use crate::runtime::openai::catalog::CodexMultiAgentMode;
896 let body = match plan.multi_agent_mode? {
897 CodexMultiAgentMode::ExplicitRequestOnly => CODEX_EXPLICIT_REQUEST_ONLY_TEXT,
898 CodexMultiAgentMode::Proactive => CODEX_PROACTIVE_MULTI_AGENT_TEXT,
899 };
900 Some(json!({
901 "type": "message",
902 "role": "developer",
903 "content": [{
904 "type": "input_text",
905 "text": format!("{CODEX_MULTI_AGENT_MODE_OPEN}{body}{CODEX_MULTI_AGENT_MODE_CLOSE}"),
906 }],
907 }))
908}
909
910fn is_codex_multi_agent_mode_item(item: &Value) -> bool {
911 item.pointer("/content/0/text")
912 .and_then(Value::as_str)
913 .is_some_and(|text| {
914 text.trim_start().starts_with(CODEX_MULTI_AGENT_MODE_OPEN)
915 && text.trim_end().ends_with(CODEX_MULTI_AGENT_MODE_CLOSE)
916 })
917}
918
919fn insert_codex_multi_agent_mode(
920 input: &mut Vec<Value>,
921 plan: &crate::runtime::openai::catalog::CodexExecutionPlan,
922) {
923 input.retain(|item| !is_codex_multi_agent_mode_item(item));
924 let Some(item) = codex_multi_agent_mode_item(plan) else {
925 return;
926 };
927 input.insert(0, item);
937}
938
939fn codex_has_required_delegation_tools(tools_schema: &[Value]) -> bool {
940 ["subagent_start", "subagent_status", "subagent_collect"]
941 .into_iter()
942 .all(|required| {
943 tools_schema.iter().any(|tool| {
944 tool.get("name").and_then(Value::as_str) == Some(required)
945 || tool.pointer("/function/name").and_then(Value::as_str) == Some(required)
946 })
947 })
948}
949
950const CODEX_AUTONOMOUS_LOOP_POLICY: &str = "\n\n[Synaps autonomous harness policy]\nThis harness is non-interactive after the user has provided the task/spec. Do not stop at phase boundaries, milestones, checkpoints, or after presenting a plan unless the full requested job is complete. Do not ask the user whether to continue. When a phase/checkpoint is reached, run any relevant verification and continue autonomously until the full requested job is complete, blocked by an unrecoverable error, or explicit user instructions require stopping.\n[End Synaps autonomous harness policy]";
951
952fn codex_instructions(system_prompt: &Option<String>) -> String {
953 let mut instructions = system_prompt.clone().unwrap_or_default();
954 if instructions.contains("[Synaps autonomous harness policy]") {
955 return instructions;
956 }
957 instructions.push_str(CODEX_AUTONOMOUS_LOOP_POLICY);
958 instructions
959}
960
961fn codex_input_messages(messages: Vec<ChatMessage>) -> Vec<Value> {
962 let mut out = Vec::new();
963 for msg in messages {
964 if msg.role == "tool" {
965 if let Some(call_id) = msg.tool_call_id {
968 out.push(json!({
969 "type": "function_call_output",
970 "call_id": call_id,
971 "output": msg.content.unwrap_or_default(),
972 }));
973 }
974 continue;
975 }
976 if msg.content_parts.is_some() || msg.content.is_some() || msg.tool_calls.is_none() {
980 let content = if let Some(parts) = msg.content_parts {
981 Value::Array(parts.into_iter().map(|part| match part {
982 ChatContentPart::Text { text } => json!({
983 "type": if msg.role == "assistant" { "output_text" } else { "input_text" },
984 "text": text,
985 }),
986 ChatContentPart::ImageUrl { image_url } => {
987 let mut part = json!({"type": "input_image", "image_url": image_url.url});
988 if let Some(detail) = image_url.detail {
989 part["detail"] = json!(detail);
990 }
991 part
992 }
993 ChatContentPart::File { file } => json!({
994 "type": "input_file",
995 "filename": file.filename,
996 "file_data": file.file_data,
997 }),
998 }).collect())
999 } else {
1000 Value::String(msg.content.unwrap_or_default())
1001 };
1002 out.push(json!({"role": msg.role, "content": content}));
1003 }
1004 if let Some(tool_calls) = msg.tool_calls {
1005 for call in tool_calls {
1006 let mut item = json!({
1013 "type": "function_call",
1014 "call_id": call.id,
1015 "name": call.function.name,
1016 "arguments": call.function.arguments,
1017 });
1018 if call.id.starts_with("fc") {
1019 item["id"] = Value::from(call.id.clone());
1020 }
1021 out.push(item);
1022 }
1023 }
1024 }
1025 out
1026}
1027
1028#[derive(Clone, Debug, Eq, PartialEq)]
1029struct ResponsesStreamFailure {
1030 code: &'static str,
1031 message: String,
1034}
1035
1036impl ResponsesStreamFailure {
1037 fn is_retryable(&self) -> bool {
1041 matches!(
1042 self.code,
1043 "responses_capacity" | "responses_empty" | "responses_missing_terminal"
1044 )
1045 }
1046}
1047
1048const CAPACITY_ERROR_CODES: &[&str] = &[
1052 "rate_limit_exceeded",
1053 "server_overloaded",
1054 "overloaded",
1055 "capacity",
1056 "server_error",
1057];
1058
1059const CAPACITY_MESSAGE_PATTERNS: &[&str] = &[
1065 "at capacity",
1066 "high demand",
1067 "try again in a few minutes",
1068 "overloaded",
1069];
1070
1071fn is_capacity_failure(error_code: &str, message: Option<&str>) -> bool {
1074 if CAPACITY_ERROR_CODES.contains(&error_code) {
1075 return true;
1076 }
1077 message.is_some_and(|m| {
1078 let lower = m.to_ascii_lowercase();
1079 CAPACITY_MESSAGE_PATTERNS.iter().any(|p| lower.contains(p))
1080 })
1081}
1082
1083struct CodexSseDecoder {
1084 provider_label: &'static str,
1087 buffer: String,
1088 active_tools: Vec<CodexToolAccumulator>,
1089 completed_tools: Vec<ToolCall>,
1090 saw_model_event: bool,
1093 terminal_success: bool,
1097 terminal_failure: Option<ResponsesStreamFailure>,
1098 emitted_output: bool,
1099 observed_usage: Option<(u64, u64, u64)>,
1102}
1103
1104impl Default for CodexSseDecoder {
1105 fn default() -> Self {
1106 Self::for_provider("Codex")
1107 }
1108}
1109
1110#[derive(Default)]
1111struct CodexToolAccumulator {
1112 id: String,
1113 name: String,
1114 arguments: String,
1115 started: bool,
1116}
1117
1118fn parse_tool_arguments(raw: &str) -> Value {
1126 if raw.trim().is_empty() {
1127 return json!({});
1128 }
1129 match serde_json::from_str(raw) {
1130 Ok(v) => v,
1131 Err(e) => json!({ "__parse_error": format!("invalid tool input JSON: {}", e) }),
1132 }
1133}
1134
1135fn sanitize_error_identifier(raw: Option<&str>) -> &str {
1144 match raw {
1145 None => "absent",
1146 Some(s) => {
1147 let identifier_shaped = !s.is_empty()
1148 && s.len() <= 64
1149 && s.bytes()
1150 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.');
1151 if identifier_shaped
1152 && s.bytes()
1153 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
1154 {
1155 s
1157 } else if identifier_shaped {
1158 "unlisted_identifier"
1161 } else {
1162 "unsafe_or_freeform"
1163 }
1164 }
1165 }
1166}
1167
1168pub(crate) const RESPONSES_AUTH_SUFFIX: &str =
1172 " authentication rejected in stream. Sign in again or switch to an available account.";
1173pub(crate) const RESPONSES_QUOTA_SUFFIX: &str =
1174 " usage quota exhausted in stream. Add credit, wait for reset, or switch models.";
1175pub(crate) const RESPONSES_FAILED_SUFFIX: &str =
1176 " response failed in stream. Provider error details withheld because they can echo request content.";
1177pub(crate) const RESPONSES_CONTEXT_SUFFIX: &str = " rejected the request: the conversation exceeds this model's context window. Run /compact or start a fresh session to continue.";
1178pub(crate) const RESPONSES_CAPACITY_SUFFIX: &str = " reports the model is at capacity (retries exhausted). Try again in a few minutes or switch models with /model.";
1179pub(crate) const RESPONSES_INCOMPLETE_SUFFIX: &str =
1180 " response was incomplete. Retry the request or reduce the requested output/context size.";
1181pub(crate) const RESPONSES_EMPTY_SUFFIX: &str =
1182 " completed without text or tool output. Retry the request.";
1183pub(crate) const RESPONSES_MISSING_TERMINAL_SUFFIX: &str =
1184 " response stream ended without a terminal event. Retry the request.";
1185
1186fn user_message_for_terminal_failure(
1192 label: &str,
1193 error_kind: &str,
1194 error_code: &str,
1195 capacity: bool,
1196) -> String {
1197 if error_code == "context_length_exceeded" || error_kind == "context_length_exceeded" {
1200 return format!("{label}{RESPONSES_CONTEXT_SUFFIX}");
1201 }
1202 let classify = |value: &str| match value {
1205 "absent" | "error" => None,
1206 "invalid_api_key" | "authentication_error" | "invalid_authentication_error" => Some("auth"),
1207 "insufficient_quota" | "quota_exceeded" | "billing_error" => Some("quota"),
1208 "rate_limit_exceeded"
1209 | "rate_limit_error"
1210 | "server_overloaded"
1211 | "overloaded"
1212 | "capacity"
1213 | "server_error"
1214 | "api_error"
1215 | "overloaded_error" => Some("capacity"),
1216 _ => Some("blocked"),
1217 };
1218 let (kind, code) = (classify(error_kind), classify(error_code));
1219 if kind == Some("blocked")
1220 || code == Some("blocked")
1221 || (kind.is_some() && code.is_some() && kind != code)
1222 {
1223 return format!("{label}{RESPONSES_FAILED_SUFFIX}");
1224 }
1225 match code.or(kind) {
1226 Some("auth") => return format!("{label}{RESPONSES_AUTH_SUFFIX}"),
1227 Some("quota") => return format!("{label}{RESPONSES_QUOTA_SUFFIX}"),
1228 Some("capacity") => return format!("{label}{RESPONSES_CAPACITY_SUFFIX}"),
1229 None if capacity => return format!("{label}{RESPONSES_CAPACITY_SUFFIX}"),
1232 _ => {}
1233 }
1234 format!("{label}{RESPONSES_FAILED_SUFFIX}")
1235}
1236
1237impl CodexSseDecoder {
1238 fn for_provider(label: &'static str) -> Self {
1240 Self {
1241 provider_label: label,
1242 buffer: String::new(),
1243 active_tools: Vec::new(),
1244 completed_tools: Vec::new(),
1245 saw_model_event: false,
1246 terminal_success: false,
1247 terminal_failure: None,
1248 emitted_output: false,
1249 observed_usage: None,
1250 }
1251 }
1252
1253 fn emitted_any_output(&self) -> bool {
1257 self.emitted_output
1258 || !self.completed_tools.is_empty()
1259 || self.active_tools.iter().any(|tool| tool.started)
1260 }
1261
1262 fn push_line(
1263 &mut self,
1264 line: &str,
1265 tx: &mpsc::UnboundedSender<StreamEvent>,
1266 text_acc: &mut String,
1267 ) {
1268 let line = line.trim_end_matches('\r');
1269 if line.is_empty() {
1270 if !self.buffer.is_empty() {
1271 let payload = std::mem::take(&mut self.buffer);
1272 self.push_payload(&payload, tx, text_acc);
1273 }
1274 return;
1275 }
1276 let Some(data) = line.strip_prefix("data:").map(str::trim_start) else {
1277 return;
1278 };
1279 if data == "[DONE]" {
1280 self.terminal_success = true;
1281 self.finish();
1282 return;
1283 }
1284 self.buffer.push_str(data);
1285 }
1286
1287 fn push_payload(
1288 &mut self,
1289 payload: &str,
1290 tx: &mpsc::UnboundedSender<StreamEvent>,
1291 text_acc: &mut String,
1292 ) {
1293 let Ok(event) = serde_json::from_str::<Value>(payload) else {
1294 tracing::debug!(
1295 payload_bytes = payload.len(),
1296 "discarding malformed Responses SSE payload"
1297 );
1298 return;
1299 };
1300 let event_type = event
1301 .get("type")
1302 .and_then(Value::as_str)
1303 .unwrap_or_default();
1304 if matches!(
1305 event_type,
1306 "response.output_text.delta"
1307 | "response.output_item.added"
1308 | "response.function_call_arguments.delta"
1309 | "response.output_item.done"
1310 | "response.completed"
1311 | "response.done"
1312 | "response.failed"
1313 | "response.incomplete"
1314 | "error"
1315 ) {
1316 self.saw_model_event = true;
1317 }
1318 match event_type {
1319 "response.output_text.delta" => {
1320 if let Some(delta) = event.get("delta").and_then(Value::as_str) {
1321 if !delta.is_empty() {
1322 self.emitted_output = true;
1323 }
1324 text_acc.push_str(delta);
1325 let _ = tx.send(StreamEvent::Llm(crate::runtime::types::LlmEvent::Text(
1326 delta.to_string(),
1327 )));
1328 }
1329 }
1330 "response.output_item.added" => {
1331 if let Some(item) = event.get("item") {
1332 let idx = event
1333 .get("output_index")
1334 .and_then(Value::as_u64)
1335 .unwrap_or(0) as usize;
1336 self.add_tool_from_item(idx, item, tx);
1337 }
1338 }
1339 "response.function_call_arguments.delta" => {
1340 let idx = event
1341 .get("output_index")
1342 .and_then(Value::as_u64)
1343 .unwrap_or(0) as usize;
1344 let delta = event
1345 .get("delta")
1346 .and_then(Value::as_str)
1347 .unwrap_or_default();
1348 if !delta.is_empty() {
1349 let tool = self.ensure_tool(idx);
1350 tool.arguments.push_str(delta);
1351 let tool_id = tool.id.clone();
1352 let _ = tx.send(StreamEvent::Llm(
1353 crate::runtime::types::LlmEvent::ToolUseDelta {
1354 tool_id,
1355 delta: delta.to_string(),
1356 },
1357 ));
1358 }
1359 }
1360 "response.output_item.done" => {
1361 if let Some(item) = event.get("item") {
1362 let idx = event
1363 .get("output_index")
1364 .and_then(Value::as_u64)
1365 .unwrap_or(0) as usize;
1366 self.complete_tool_from_item(idx, item, tx);
1367 }
1368 }
1369 "response.completed" | "response.done" => {
1370 self.push_usage(&event, tx);
1371 self.terminal_success = true;
1372 self.finish();
1373 }
1374 "response.failed" | "error" => {
1375 self.push_usage(&event, tx);
1376 let error_obj = event
1387 .get("response")
1388 .and_then(|r| r.get("error"))
1389 .or_else(|| event.get("error"))
1390 .filter(|e| e.is_object());
1391 let error_kind = sanitize_error_identifier(
1392 error_obj
1393 .and_then(|e| e.get("type"))
1394 .and_then(Value::as_str),
1395 );
1396 let error_code = sanitize_error_identifier(
1397 error_obj
1398 .and_then(|e| e.get("code"))
1399 .or_else(|| event.get("code"))
1400 .and_then(Value::as_str),
1401 );
1402 let capacity = is_capacity_failure(
1405 error_code,
1406 error_obj
1407 .and_then(|e| e.get("message"))
1408 .or_else(|| event.get("message"))
1409 .and_then(Value::as_str),
1410 );
1411 tracing::warn!(
1412 provider = self.provider_label,
1413 event_type,
1414 error_kind,
1415 error_code,
1416 capacity,
1417 "responses stream terminal failure (provider message withheld)"
1418 );
1419 let message = user_message_for_terminal_failure(
1420 self.provider_label,
1421 error_kind,
1422 error_code,
1423 capacity,
1424 );
1425 self.terminal_failure = Some(ResponsesStreamFailure {
1426 code: if message
1429 == format!("{}{RESPONSES_CAPACITY_SUFFIX}", self.provider_label)
1430 {
1431 "responses_capacity"
1432 } else {
1433 "responses_failed"
1434 },
1435 message,
1436 });
1437 self.finish();
1438 }
1439 "response.incomplete" => {
1440 self.push_usage(&event, tx);
1441 let reason = sanitize_error_identifier(
1442 event
1443 .get("response")
1444 .and_then(|r| r.get("incomplete_details"))
1445 .and_then(|d| d.get("reason"))
1446 .and_then(Value::as_str),
1447 );
1448 tracing::warn!(
1449 provider = self.provider_label,
1450 reason,
1451 "responses stream terminal incomplete"
1452 );
1453 self.terminal_failure = Some(ResponsesStreamFailure {
1454 code: "responses_incomplete",
1455 message: format!("{}{RESPONSES_INCOMPLETE_SUFFIX}", self.provider_label),
1456 });
1457 self.finish();
1458 }
1459 _ => {
1460 if !event_type.is_empty() {
1461 tracing::debug!(event_type, "ignoring unknown Responses SSE event");
1462 }
1463 }
1464 }
1465 }
1466
1467 fn terminal_result(&self) -> Result<(), ResponsesStreamFailure> {
1468 if let Some(failure) = &self.terminal_failure {
1469 return Err(failure.clone());
1470 }
1471 if self.terminal_success {
1472 if self.emitted_output || !self.completed_tools.is_empty() {
1473 Ok(())
1474 } else {
1475 Err(ResponsesStreamFailure {
1476 code: "responses_empty",
1477 message: format!("{}{RESPONSES_EMPTY_SUFFIX}", self.provider_label),
1478 })
1479 }
1480 } else {
1481 Err(ResponsesStreamFailure {
1482 code: "responses_missing_terminal",
1483 message: format!("{}{RESPONSES_MISSING_TERMINAL_SUFFIX}", self.provider_label),
1484 })
1485 }
1486 }
1487
1488 fn ensure_tool(&mut self, idx: usize) -> &mut CodexToolAccumulator {
1489 const MAX_TOOLS: usize = 4096;
1494 let idx = idx.min(MAX_TOOLS - 1);
1495 while self.active_tools.len() <= idx {
1496 self.active_tools.push(CodexToolAccumulator::default());
1497 }
1498 &mut self.active_tools[idx]
1499 }
1500
1501 fn add_tool_from_item(
1502 &mut self,
1503 idx: usize,
1504 item: &Value,
1505 tx: &mpsc::UnboundedSender<StreamEvent>,
1506 ) {
1507 if item.get("type").and_then(Value::as_str) != Some("function_call") {
1508 return;
1509 }
1510 let tool = self.ensure_tool(idx);
1511 if let Some(id) = item
1512 .get("call_id")
1513 .or_else(|| item.get("id"))
1514 .and_then(Value::as_str)
1515 {
1516 tool.id = id.to_string();
1517 }
1518 if let Some(name) = item.get("name").and_then(Value::as_str) {
1519 tool.name = name.to_string();
1520 }
1521 if !tool.started && !tool.name.is_empty() {
1522 tool.started = true;
1523 let _ = tx.send(StreamEvent::Llm(
1524 crate::runtime::types::LlmEvent::ToolUseStart {
1525 tool_name: tool.name.clone(),
1526 tool_id: tool.id.clone(),
1527 },
1528 ));
1529 }
1530 }
1531
1532 fn complete_tool_from_item(
1533 &mut self,
1534 idx: usize,
1535 item: &Value,
1536 tx: &mpsc::UnboundedSender<StreamEvent>,
1537 ) {
1538 if item.get("type").and_then(Value::as_str) != Some("function_call") {
1539 return;
1540 }
1541 let tool = self.ensure_tool(idx);
1542 if let Some(id) = item
1543 .get("call_id")
1544 .or_else(|| item.get("id"))
1545 .and_then(Value::as_str)
1546 {
1547 tool.id = id.to_string();
1548 }
1549 if let Some(name) = item.get("name").and_then(Value::as_str) {
1550 tool.name = name.to_string();
1551 }
1552 if let Some(arguments) = item.get("arguments").and_then(Value::as_str) {
1553 tool.arguments = arguments.to_string();
1554 }
1555 if !tool.started && !tool.name.is_empty() {
1556 tool.started = true;
1557 let _ = tx.send(StreamEvent::Llm(
1558 crate::runtime::types::LlmEvent::ToolUseStart {
1559 tool_name: tool.name.clone(),
1560 tool_id: tool.id.clone(),
1561 },
1562 ));
1563 }
1564 let completed = if !tool.id.is_empty() && !tool.name.is_empty() {
1565 Some(ToolCall {
1566 id: tool.id.clone(),
1567 kind: "function".to_string(),
1568 function: super::types::FunctionCall {
1569 name: tool.name.clone(),
1570 arguments: tool.arguments.clone(),
1571 },
1572 })
1573 } else {
1574 None
1575 };
1576 if let Some(call) = completed {
1577 if self.completed_tools.iter().any(|done| done.id == call.id) {
1578 return;
1579 }
1580 self.emitted_output = true;
1581 let input = parse_tool_arguments(&call.function.arguments);
1589 let _ = tx.send(StreamEvent::Llm(crate::runtime::types::LlmEvent::ToolUse {
1590 tool_name: call.function.name.clone(),
1591 tool_id: call.id.clone(),
1592 input,
1593 }));
1594 self.completed_tools.push(ToolCall {
1595 id: call.id,
1596 kind: call.kind,
1597 function: call.function,
1598 });
1599 }
1600 }
1601
1602 fn push_usage(&mut self, event: &Value, tx: &mpsc::UnboundedSender<StreamEvent>) {
1603 let usage = event
1604 .get("response")
1605 .and_then(|r| r.get("usage"))
1606 .or_else(|| event.get("usage"));
1607 let input = usage
1608 .and_then(|u| u.get("input_tokens"))
1609 .and_then(Value::as_u64)
1610 .unwrap_or(0);
1611 let output = usage
1612 .and_then(|u| u.get("output_tokens"))
1613 .and_then(Value::as_u64)
1614 .unwrap_or(0);
1615 let cached = usage
1622 .and_then(|u| u.pointer("/input_tokens_details/cached_tokens"))
1623 .and_then(Value::as_u64)
1624 .unwrap_or(0)
1625 .min(input);
1626 if input > 0 || output > 0 {
1627 if self.observed_usage.is_none() {
1630 self.observed_usage = Some((input, output, cached));
1631 }
1632 let _ = tx.send(StreamEvent::Session(
1633 crate::runtime::types::SessionEvent::Usage {
1634 input_tokens: input - cached,
1635 output_tokens: output,
1636 cache_read_input_tokens: cached,
1637 cache_creation_input_tokens: 0,
1638 cache_creation_5m: None,
1639 cache_creation_1h: None,
1640 model: None,
1641 },
1642 ));
1643 }
1644 }
1645
1646 fn trace_usage(&self) -> Option<crate::runtime::trace::UsageMeta> {
1648 self.observed_usage
1649 .map(|(input, output, cached)| tr::provider_usage(input, output, cached))
1650 }
1651
1652 fn finish(&mut self) {
1653 for tool in self.active_tools.drain(..) {
1654 if !tool.id.is_empty()
1655 && !tool.name.is_empty()
1656 && !self.completed_tools.iter().any(|done| done.id == tool.id)
1657 {
1658 self.emitted_output = true;
1659 self.completed_tools.push(ToolCall {
1660 id: tool.id,
1661 kind: "function".to_string(),
1662 function: super::types::FunctionCall {
1663 name: tool.name,
1664 arguments: tool.arguments,
1665 },
1666 });
1667 }
1668 }
1669 }
1670}
1671
1672fn handle_events(
1673 events: &[OaiEvent],
1674 tx: &mpsc::UnboundedSender<StreamEvent>,
1675 text_acc: &mut String,
1676 tool_blocks: &mut Vec<Value>,
1677 name_map: &translate::ToolNameMap,
1678 suppress_stream_deltas: bool,
1679) {
1680 for ev in events {
1681 if let OaiEvent::TextDelta(t) = ev {
1682 text_acc.push_str(t);
1683 }
1684 if let OaiEvent::ToolCallsComplete { calls, .. } = ev {
1685 tool_blocks.extend(translate::tool_calls_to_content_blocks(calls, name_map));
1686 }
1687 if !suppress_stream_deltas {
1688 if let Some(se) = translate::oai_event_to_llm(ev) {
1689 let _ = tx.send(se);
1690 }
1691 }
1692 }
1693}
1694
1695#[cfg(test)]
1696mod codex_input_messages_tests {
1697 use super::super::types::{ChatMessage, FunctionCall, ToolCall};
1713 use super::*;
1714
1715 fn sample_tool_call() -> ToolCall {
1716 ToolCall {
1717 id: "call_nZYquCuGUh8Qs9H51dwHMDgs".to_string(),
1718 kind: "function".to_string(),
1719 function: FunctionCall {
1720 name: "bash".to_string(),
1721 arguments: r#"{"command":"ls"}"#.to_string(),
1722 },
1723 }
1724 }
1725
1726 fn translated_input(messages: Vec<Value>) -> Vec<Value> {
1727 let messages: Vec<crate::SharedMessage> =
1728 messages.into_iter().map(std::sync::Arc::new).collect();
1729 codex_input_messages(translate::messages_to_oai(
1730 &messages,
1731 &None,
1732 &translate::ToolNameMap::default(),
1733 ))
1734 }
1735
1736 #[test]
1737 fn image_text_document_and_pdf_exact_responses_wire() {
1738 let out = translated_input(vec![json!({"role":"user","content":[
1739 {"type":"text","text":"Compare these."},
1740 {"type":"image","source":{"type":"base64","media_type":"image/png","data":"aW1hZ2U="}},
1741 {"type":"document","title":"notes.txt","source":{"type":"text","media_type":"text/plain","data":"Text document\ncontents"}},
1742 {"type":"document","title":"report.pdf","source":{"type":"base64","media_type":"application/pdf","data":"JVBERi0="}},
1743 {"type":"text","text":"Then summarize."}
1744 ]})]);
1745 assert_eq!(
1746 out,
1747 vec![json!({"role":"user","content":[
1748 {"type":"input_text","text":"Compare these."},
1749 {"type":"input_text","text":"[attachment origin=user type=image media_type=\"image/png\"; lower-authority data]\n"},
1750 {"type":"input_image","image_url":"data:image/png;base64,aW1hZ2U="},
1751 {"type":"input_text","text":"[/attachment]\n"},
1752 {"type":"input_text","text":"[attachment origin=user type=document media_type=\"text/plain\" filename=\"notes.txt\"; lower-authority data]\n"},
1753 {"type":"input_text","text":"Text document\ncontents"},
1754 {"type":"input_text","text":"[/attachment]\n"},
1755 {"type":"input_text","text":"[attachment origin=user type=document media_type=\"application/pdf\" filename=\"report.pdf\"; lower-authority data]\n"},
1756 {"type":"input_file","filename":"report.pdf","file_data":"data:application/pdf;base64,JVBERi0="},
1757 {"type":"input_text","text":"[/attachment]\n"},
1758 {"type":"input_text","text":"Then summarize."}
1759 ]})]
1760 );
1761 }
1762
1763 #[test]
1764 fn mixed_assistant_text_and_all_tool_outputs_precede_lifted_responses_media() {
1765 let out = translated_input(vec![
1766 json!({"role":"assistant","content":[
1767 {"type":"text","text":"Reading both."},
1768 {"type":"tool_use","id":"t1","name":"read","input":{}},
1769 {"type":"tool_use","id":"t2","name":"read","input":{}}
1770 ]}),
1771 json!({"role":"user","content":[
1772 {"type":"tool_result","tool_use_id":"t1","content":[
1773 {"type":"text","text":"First result"},
1774 {"type":"image","source":{"type":"base64","media_type":"image/png","data":"aW1hZ2U="}},
1775 {"type":"document","title":"notes.txt","source":{"type":"text","media_type":"text/plain","data":"DOCUMENT_BODY_SENTINEL"}}
1776 ]},
1777 {"type":"tool_result","tool_use_id":"t2","content":[
1778 {"type":"text","text":"Second result"},
1779 {"type":"document","title":"report.pdf","source":{"type":"base64","media_type":"application/pdf","data":"JVBERi0="}}
1780 ]}
1781 ]}),
1782 ]);
1783 assert_eq!(
1784 out,
1785 vec![
1786 json!({"role":"assistant","content":"Reading both."}),
1787 json!({"type":"function_call","call_id":"t1","name":"read","arguments":"{}"}),
1788 json!({"type":"function_call","call_id":"t2","name":"read","arguments":"{}"}),
1789 json!({"type":"function_call_output","call_id":"t1","output":"First result\n[attachment origin=tool_result tool_use_id=\"t1\" tool_name=\"read\" type=image media_type=\"image/png\"; lower-authority data; supplied in following user content]\n\n[attachment origin=tool_result tool_use_id=\"t1\" tool_name=\"read\" type=document media_type=\"text/plain\" filename=\"notes.txt\"; lower-authority data; supplied in following user content]\n"}),
1790 json!({"type":"function_call_output","call_id":"t2","output":"Second result\n[attachment origin=tool_result tool_use_id=\"t2\" tool_name=\"read\" type=document media_type=\"application/pdf\" filename=\"report.pdf\"; lower-authority data; supplied in following user content]\n"}),
1791 json!({"role":"user","content":[
1792 {"type":"input_text","text":"[attachment origin=tool_result tool_use_id=\"t1\" tool_name=\"read\" type=image media_type=\"image/png\"; lower-authority data]\n"},
1793 {"type":"input_image","image_url":"data:image/png;base64,aW1hZ2U="},
1794 {"type":"input_text","text":"[/attachment]\n"},
1795 {"type":"input_text","text":"[attachment origin=tool_result tool_use_id=\"t1\" tool_name=\"read\" type=document media_type=\"text/plain\" filename=\"notes.txt\"; lower-authority data]\n"},
1796 {"type":"input_text","text":"DOCUMENT_BODY_SENTINEL"},
1797 {"type":"input_text","text":"[/attachment]\n"},
1798 {"type":"input_text","text":"[attachment origin=tool_result tool_use_id=\"t2\" tool_name=\"read\" type=document media_type=\"application/pdf\" filename=\"report.pdf\"; lower-authority data]\n"},
1799 {"type":"input_file","filename":"report.pdf","file_data":"data:application/pdf;base64,JVBERi0="},
1800 {"type":"input_text","text":"[/attachment]\n"}
1801 ]})
1802 ]
1803 );
1804 for output in out
1805 .iter()
1806 .filter(|item| item["type"] == "function_call_output")
1807 {
1808 let text = output["output"].as_str().unwrap();
1809 for sentinel in [
1810 "aW1hZ2U=",
1811 "JVBERi0=",
1812 "DOCUMENT_BODY_SENTINEL",
1813 "\"source\"",
1814 ] {
1815 assert!(
1816 !text.contains(sentinel),
1817 "tool output must contain only text and metadata"
1818 );
1819 }
1820 }
1821 }
1822
1823 #[test]
1824 fn text_only_responses_input_keeps_strings_and_mixed_assistant_text() {
1825 let mut assistant = ChatMessage::assistant_tool_calls(vec![sample_tool_call()]);
1826 assistant.content = Some("Checking.".into());
1827 let out = codex_input_messages(vec![
1828 ChatMessage::system("rules"),
1829 ChatMessage::user("question"),
1830 assistant,
1831 ChatMessage::tool_result("call_nZYquCuGUh8Qs9H51dwHMDgs", "bash", "done"),
1832 ChatMessage::assistant("answer"),
1833 ]);
1834 assert_eq!(
1835 out,
1836 vec![
1837 json!({"role":"system","content":"rules"}),
1838 json!({"role":"user","content":"question"}),
1839 json!({"role":"assistant","content":"Checking."}),
1840 json!({"type":"function_call","call_id":"call_nZYquCuGUh8Qs9H51dwHMDgs","name":"bash","arguments":"{\"command\":\"ls\"}"}),
1841 json!({"type":"function_call_output","call_id":"call_nZYquCuGUh8Qs9H51dwHMDgs","output":"done"}),
1842 json!({"role":"assistant","content":"answer"}),
1843 ]
1844 );
1845 }
1846
1847 #[test]
1848 fn codex_instructions_appends_autonomous_loop_policy() {
1849 let instructions = codex_instructions(&Some("Project-specific rules.".to_string()));
1850 assert!(instructions.contains("Project-specific rules."));
1851 assert!(instructions.contains("Do not stop at phase boundaries"));
1852 assert!(instructions.contains("Do not ask the user whether to continue"));
1853 assert!(
1854 instructions.contains("continue autonomously until the full requested job is complete")
1855 );
1856 }
1857
1858 #[test]
1859 fn function_call_input_omits_non_fc_id() {
1860 let messages = vec![ChatMessage::assistant_tool_calls(vec![sample_tool_call()])];
1861 let out = codex_input_messages(messages);
1862 assert_eq!(out.len(), 1, "one tool_call → one input item");
1863 let item = &out[0];
1864 assert_eq!(
1865 item.get("type").and_then(Value::as_str),
1866 Some("function_call")
1867 );
1868 assert!(
1869 item.get("id").is_none(),
1870 "must not echo a non-`fc_` id back; got {:?}",
1871 item.get("id"),
1872 );
1873 assert_eq!(
1874 item.get("call_id").and_then(Value::as_str),
1875 Some("call_nZYquCuGUh8Qs9H51dwHMDgs"),
1876 );
1877 assert_eq!(item.get("name").and_then(Value::as_str), Some("bash"));
1878 }
1879
1880 #[test]
1881 fn function_call_input_keeps_real_fc_id() {
1882 let mut call = sample_tool_call();
1885 call.id = "fc_abc123".to_string();
1886 let messages = vec![ChatMessage::assistant_tool_calls(vec![call])];
1887 let out = codex_input_messages(messages);
1888 let item = &out[0];
1889 assert_eq!(item.get("id").and_then(Value::as_str), Some("fc_abc123"));
1890 assert_eq!(
1891 item.get("call_id").and_then(Value::as_str),
1892 Some("fc_abc123")
1893 );
1894 }
1895
1896 #[test]
1897 fn function_call_output_round_trips_call_id() {
1898 let messages = vec![ChatMessage::tool_result(
1900 "call_nZYquCuGUh8Qs9H51dwHMDgs",
1901 "bash",
1902 "total 0",
1903 )];
1904 let out = codex_input_messages(messages);
1905 let item = &out[0];
1906 assert_eq!(
1907 item.get("type").and_then(Value::as_str),
1908 Some("function_call_output"),
1909 );
1910 assert_eq!(
1911 item.get("call_id").and_then(Value::as_str),
1912 Some("call_nZYquCuGUh8Qs9H51dwHMDgs"),
1913 );
1914 assert_eq!(item.get("output").and_then(Value::as_str), Some("total 0"));
1915 }
1916}
1917
1918#[cfg(test)]
1919mod codex_decoder_tests {
1920 use super::*;
1927 use crate::runtime::types::{LlmEvent, SessionEvent, StreamEvent};
1928
1929 fn collect_events(rx: &mut mpsc::UnboundedReceiver<StreamEvent>) -> Vec<StreamEvent> {
1930 let mut out = Vec::new();
1931 while let Ok(ev) = rx.try_recv() {
1932 out.push(ev);
1933 }
1934 out
1935 }
1936
1937 fn drive(lines: &[&str]) -> (CodexSseDecoder, String, Vec<StreamEvent>) {
1938 let (tx, mut rx) = mpsc::unbounded_channel();
1939 let mut decoder = CodexSseDecoder::default();
1940 let mut text_acc = String::new();
1941 for line in lines {
1942 decoder.push_line(line, &tx, &mut text_acc);
1943 }
1944 let events = collect_events(&mut rx);
1945 (decoder, text_acc, events)
1946 }
1947
1948 #[test]
1949 fn hostile_output_index_does_not_grow_active_tools_unbounded() {
1950 let lines = [
1955 r#"data: {"type":"response.function_call_arguments.delta","output_index":1000000000,"delta":"x"}"#,
1956 "",
1957 ];
1958 let (decoder, _text, _events) = drive(&lines);
1959 assert!(
1960 decoder.active_tools.len() <= 4096,
1961 "active_tools grew to {} — output_index not bounded",
1962 decoder.active_tools.len()
1963 );
1964 }
1965
1966 #[test]
1967 fn text_delta_aggregates_into_text_acc_and_emits_text_events() {
1968 let lines = [
1969 r#"data: {"type":"response.output_text.delta","delta":"Hello, "}"#,
1970 "",
1971 r#"data: {"type":"response.output_text.delta","delta":"world!"}"#,
1972 "",
1973 ];
1974 let (_decoder, text_acc, events) = drive(&lines);
1975 assert_eq!(text_acc, "Hello, world!");
1976 let texts: Vec<_> = events
1977 .iter()
1978 .filter_map(|e| match e {
1979 StreamEvent::Llm(LlmEvent::Text(t)) => Some(t.as_str()),
1980 _ => None,
1981 })
1982 .collect();
1983 assert_eq!(texts, vec!["Hello, ", "world!"]);
1984 }
1985
1986 #[test]
1987 fn single_function_call_completes_via_output_item_done() {
1988 let lines = [
1989 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_abc","name":"bash"}}"#,
1990 "",
1991 r#"data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"{\"cmd\""}"#,
1992 "",
1993 r#"data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":":\"ls\"}"}"#,
1994 "",
1995 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_abc","name":"bash","arguments":"{\"cmd\":\"ls\"}"}}"#,
1996 "",
1997 ];
1998 let (decoder, _text, events) = drive(&lines);
1999
2000 assert_eq!(decoder.completed_tools.len(), 1);
2001 let tool = &decoder.completed_tools[0];
2002 assert_eq!(tool.id, "call_abc");
2003 assert_eq!(tool.function.name, "bash");
2004 assert_eq!(tool.function.arguments, r#"{"cmd":"ls"}"#);
2005
2006 let starts: Vec<_> = events
2008 .iter()
2009 .filter_map(|e| match e {
2010 StreamEvent::Llm(LlmEvent::ToolUseStart { tool_name, tool_id }) => {
2011 Some((tool_name.as_str(), tool_id.as_str()))
2012 }
2013 _ => None,
2014 })
2015 .collect();
2016 assert_eq!(
2017 starts,
2018 vec![("bash", "call_abc")],
2019 "exactly one ToolUseStart with correct tool_id"
2020 );
2021
2022 let deltas: Vec<_> = events
2025 .iter()
2026 .filter_map(|e| match e {
2027 StreamEvent::Llm(LlmEvent::ToolUseDelta { tool_id, delta }) => {
2028 Some((tool_id.as_str(), delta.as_str()))
2029 }
2030 _ => None,
2031 })
2032 .collect();
2033 assert_eq!(
2034 deltas,
2035 vec![("call_abc", r#"{"cmd""#), ("call_abc", r#":"ls"}"#)]
2036 );
2037 }
2038
2039 #[test]
2040 fn parallel_tool_calls_indexed_separately() {
2041 let lines = [
2042 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"bash"}}"#,
2043 "",
2044 r#"data: {"type":"response.output_item.added","output_index":1,"item":{"type":"function_call","call_id":"call_2","name":"read"}}"#,
2045 "",
2046 r#"data: {"type":"response.function_call_arguments.delta","output_index":1,"delta":"{\"path\":\"a\"}"}"#,
2047 "",
2048 r#"data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"{\"cmd\":\"ls\"}"}"#,
2049 "",
2050 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"bash","arguments":"{\"cmd\":\"ls\"}"}}"#,
2051 "",
2052 r#"data: {"type":"response.output_item.done","output_index":1,"item":{"type":"function_call","call_id":"call_2","name":"read","arguments":"{\"path\":\"a\"}"}}"#,
2053 "",
2054 ];
2055 let (decoder, _text, _events) = drive(&lines);
2056
2057 assert_eq!(decoder.completed_tools.len(), 2);
2058 let mut by_id: std::collections::BTreeMap<&str, &ToolCall> =
2059 std::collections::BTreeMap::new();
2060 for tool in &decoder.completed_tools {
2061 by_id.insert(tool.id.as_str(), tool);
2062 }
2063 assert_eq!(by_id["call_1"].function.name, "bash");
2064 assert_eq!(by_id["call_1"].function.arguments, r#"{"cmd":"ls"}"#);
2065 assert_eq!(by_id["call_2"].function.name, "read");
2066 assert_eq!(by_id["call_2"].function.arguments, r#"{"path":"a"}"#);
2067 }
2068
2069 #[test]
2070 fn output_item_done_emits_tool_use_event() {
2071 let lines = [
2078 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_abc","name":"bash"}}"#,
2079 "",
2080 r#"data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"{\"command\":\"ls\"}"}"#,
2081 "",
2082 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_abc","name":"bash","arguments":"{\"command\":\"ls\"}"}}"#,
2083 "",
2084 ];
2085 let (_decoder, _text, events) = drive(&lines);
2086
2087 let tool_uses: Vec<_> = events
2088 .iter()
2089 .filter_map(|e| match e {
2090 StreamEvent::Llm(LlmEvent::ToolUse {
2091 tool_name,
2092 tool_id,
2093 input,
2094 }) => Some((tool_name.as_str(), tool_id.as_str(), input.clone())),
2095 _ => None,
2096 })
2097 .collect();
2098 assert_eq!(
2099 tool_uses.len(),
2100 1,
2101 "expected exactly one ToolUse finalize event"
2102 );
2103 assert_eq!(tool_uses[0].0, "bash");
2104 assert_eq!(tool_uses[0].1, "call_abc");
2105 assert_eq!(
2106 tool_uses[0].2,
2107 serde_json::json!({"command": "ls"}),
2108 "input must be parsed as a JSON Value, not a string"
2109 );
2110 }
2111
2112 #[test]
2113 fn parallel_tool_calls_emit_tool_use_per_index() {
2114 let lines = [
2118 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"bash"}}"#,
2119 "",
2120 r#"data: {"type":"response.output_item.added","output_index":1,"item":{"type":"function_call","call_id":"call_2","name":"read"}}"#,
2121 "",
2122 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"bash","arguments":"{\"command\":\"ls\"}"}}"#,
2123 "",
2124 r#"data: {"type":"response.output_item.done","output_index":1,"item":{"type":"function_call","call_id":"call_2","name":"read","arguments":"{\"path\":\"a\"}"}}"#,
2125 "",
2126 ];
2127 let (_decoder, _text, events) = drive(&lines);
2128
2129 let tool_uses: Vec<_> = events
2130 .iter()
2131 .filter_map(|e| match e {
2132 StreamEvent::Llm(LlmEvent::ToolUse {
2133 tool_name,
2134 tool_id,
2135 input,
2136 }) => Some((tool_name.clone(), tool_id.clone(), input.clone())),
2137 _ => None,
2138 })
2139 .collect();
2140
2141 assert_eq!(tool_uses.len(), 2, "one ToolUse finalize per parallel call");
2142 let by_id: std::collections::BTreeMap<&str, &(String, String, serde_json::Value)> =
2143 tool_uses.iter().map(|t| (t.1.as_str(), t)).collect();
2144 assert_eq!(by_id["call_1"].0, "bash");
2145 assert_eq!(by_id["call_1"].2, serde_json::json!({"command": "ls"}));
2146 assert_eq!(by_id["call_2"].0, "read");
2147 assert_eq!(by_id["call_2"].2, serde_json::json!({"path": "a"}));
2148 }
2149
2150 #[test]
2151 fn malformed_arguments_emit_tool_use_with_parse_error() {
2152 let lines = [
2157 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_bad","name":"bash"}}"#,
2158 "",
2159 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_bad","name":"bash","arguments":"{not json"}}"#,
2160 "",
2161 ];
2162 let (_decoder, _text, events) = drive(&lines);
2163
2164 let tool_use = events.iter().find_map(|e| match e {
2165 StreamEvent::Llm(LlmEvent::ToolUse { input, .. }) => Some(input.clone()),
2166 _ => None,
2167 });
2168 let input = tool_use.expect("ToolUse event missing");
2169 assert!(
2170 input.get("__parse_error").and_then(Value::as_str).is_some(),
2171 "malformed arguments must surface __parse_error, got {input}"
2172 );
2173 }
2174
2175 #[test]
2176 fn response_completed_emits_usage_event() {
2177 let lines = [
2178 r#"data: {"type":"response.completed","response":{"usage":{"input_tokens":42,"output_tokens":17}}}"#,
2179 "",
2180 ];
2181 let (_decoder, _text, events) = drive(&lines);
2182 let usage = events.iter().find_map(|e| match e {
2183 StreamEvent::Session(SessionEvent::Usage {
2184 input_tokens,
2185 output_tokens,
2186 ..
2187 }) => Some((*input_tokens, *output_tokens)),
2188 _ => None,
2189 });
2190 assert_eq!(usage, Some((42, 17)));
2191 }
2192
2193 #[test]
2199 fn response_completed_surfaces_cached_tokens_without_double_count() {
2200 let lines = [
2201 r#"data: {"type":"response.completed","response":{"usage":{"input_tokens":1000,"input_tokens_details":{"cached_tokens":900},"output_tokens":17}}}"#,
2202 "",
2203 ];
2204 let (_decoder, _text, events) = drive(&lines);
2205 let usage = events.iter().find_map(|e| match e {
2206 StreamEvent::Session(SessionEvent::Usage {
2207 input_tokens,
2208 output_tokens,
2209 cache_read_input_tokens,
2210 ..
2211 }) => Some((*input_tokens, *output_tokens, *cache_read_input_tokens)),
2212 _ => None,
2213 });
2214 assert_eq!(
2215 usage,
2216 Some((100, 17, 900)),
2217 "input must exclude the cached slice; cache_read must carry it"
2218 );
2219 }
2220
2221 #[test]
2222 fn response_completed_with_zero_usage_emits_nothing() {
2223 let lines = [
2224 r#"data: {"type":"response.output_text.delta","delta":"ok"}"#,
2225 "",
2226 r#"data: {"type":"response.completed","response":{"usage":{"input_tokens":0,"output_tokens":0}}}"#,
2227 "",
2228 ];
2229 let (decoder, _text, events) = drive(&lines);
2230 let any_usage = events
2231 .iter()
2232 .any(|e| matches!(e, StreamEvent::Session(SessionEvent::Usage { .. })));
2233 assert!(!any_usage, "zero-token usage should be suppressed");
2234 assert_eq!(decoder.terminal_result(), Ok(()));
2235 }
2236
2237 #[test]
2238 fn response_failed_is_a_terminal_failure_without_retaining_provider_text() {
2239 let lines = [
2240 r#"data: {"type":"response.failed","response":{"error":{"type":"server_error","message":"ECHOED:secret prompt"}}}"#,
2241 "",
2242 ];
2243 let (decoder, text, events) = drive(&lines);
2244 let failure = decoder.terminal_result().expect_err("must fail");
2245 assert_eq!(failure.code, "responses_capacity");
2247 assert!(failure.is_retryable());
2248 assert_eq!(failure.message, format!("Codex{RESPONSES_CAPACITY_SUFFIX}"));
2249 assert!(!failure.message.contains("ECHOED"));
2250 assert!(text.is_empty());
2251 assert!(events.is_empty());
2252 }
2253
2254 #[test]
2255 fn top_level_error_event_is_a_terminal_failure() {
2256 let lines = [
2257 r#"data: {"type":"error","error":{"type":"server_error","message":"private"}}"#,
2258 "",
2259 ];
2260 let (decoder, _text, _events) = drive(&lines);
2261 assert_eq!(
2262 decoder.terminal_result().expect_err("must fail").code,
2263 "responses_capacity"
2264 );
2265 }
2266
2267 #[test]
2268 fn response_incomplete_is_not_misreported_as_empty_success() {
2269 let lines = [
2270 r#"data: {"type":"response.incomplete","response":{"incomplete_details":{"reason":"max_output_tokens"}}}"#,
2271 "",
2272 ];
2273 let (decoder, _text, _events) = drive(&lines);
2274 let failure = decoder.terminal_result().expect_err("must fail");
2275 assert_eq!(failure.code, "responses_incomplete");
2276 assert!(failure.message.contains("incomplete"));
2277 assert!(!failure.message.contains("max_output_tokens"));
2278 }
2279
2280 #[test]
2281 fn completed_without_text_or_tools_fails_at_transport_boundary() {
2282 let lines = [
2283 r#"data: {"type":"response.completed","response":{"usage":{"input_tokens":7,"output_tokens":0}}}"#,
2284 "",
2285 ];
2286 let (decoder, _text, _events) = drive(&lines);
2287 assert_eq!(
2288 decoder.terminal_result().expect_err("must fail").code,
2289 "responses_empty"
2290 );
2291 }
2292
2293 #[test]
2294 fn eof_without_terminal_event_fails_closed() {
2295 let lines = [
2296 r#"data: {"type":"response.created","response":{"id":"resp_123"}}"#,
2297 "",
2298 ];
2299 let (decoder, _text, _events) = drive(&lines);
2300 assert_eq!(
2301 decoder.terminal_result().expect_err("must fail").code,
2302 "responses_missing_terminal"
2303 );
2304 }
2305
2306 #[test]
2310 fn error_identifier_sanitizer_never_passes_freeform_text() {
2311 assert_eq!(sanitize_error_identifier(None), "absent");
2312 assert_eq!(
2313 sanitize_error_identifier(Some("rate_limit_exceeded")),
2314 "rate_limit_exceeded"
2315 );
2316 assert_eq!(
2317 sanitize_error_identifier(Some("context_length_exceeded")),
2318 "context_length_exceeded"
2319 );
2320 assert_eq!(
2321 sanitize_error_identifier(Some("some_new_code2")),
2322 "some_new_code2"
2323 );
2324 assert_eq!(
2326 sanitize_error_identifier(Some("sk-Abc123XYZ")),
2327 "unlisted_identifier"
2328 );
2329 assert_eq!(
2330 sanitize_error_identifier(Some("Server.Error")),
2331 "unlisted_identifier"
2332 );
2333 assert_eq!(
2334 sanitize_error_identifier(Some("ECHOED: user secret prompt")),
2335 "unsafe_or_freeform"
2336 );
2337 assert_eq!(sanitize_error_identifier(Some("")), "unsafe_or_freeform");
2338 let long = "a".repeat(65);
2339 assert_eq!(sanitize_error_identifier(Some(&long)), "unsafe_or_freeform");
2340 }
2341
2342 #[test]
2346 fn terminal_failure_user_message_is_specific_only_for_known_codes() {
2347 assert!(user_message_for_terminal_failure(
2348 "Codex",
2349 "invalid_request_error",
2350 "context_length_exceeded",
2351 false
2352 )
2353 .contains("/compact"));
2354 assert!(user_message_for_terminal_failure(
2356 "Codex",
2357 "context_length_exceeded",
2358 "absent",
2359 false
2360 )
2361 .contains("context window"));
2362 let default = user_message_for_terminal_failure(
2364 "Codex",
2365 "server_error",
2366 "unlisted_identifier",
2367 false,
2368 );
2369 assert!(default.starts_with("Codex response failed in stream."));
2370 assert!(!default.contains("/compact"));
2371 let capacity = user_message_for_terminal_failure("xAI", "absent", "absent", true);
2373 assert_eq!(
2374 capacity,
2375 "xAI reports the model is at capacity (retries exhausted). Try again in a few minutes or switch models with /model."
2376 );
2377 }
2378
2379 #[test]
2384 fn xai_flat_capacity_error_is_retryable_capacity_failure() {
2385 let lines = [
2386 r#"data: {"sequence_number":0,"type":"error","code":null,"message":"The model is currently at capacity due to high demand. Please try again in a few minutes, or use a higher service tier for priority processing: https://docs.x.ai/developers/advanced-api-usage/priority-processing","param":null}"#,
2387 "",
2388 ];
2389 let (tx, mut rx) = mpsc::unbounded_channel();
2390 let mut decoder = CodexSseDecoder::for_provider("xAI");
2391 let mut text = String::new();
2392 for line in lines {
2393 decoder.push_line(line, &tx, &mut text);
2394 }
2395 decoder.finish();
2396 let failure = decoder.terminal_result().expect_err("must fail");
2397 assert_eq!(failure.code, "responses_capacity");
2398 assert!(failure.is_retryable());
2399 assert_eq!(
2400 failure.message,
2401 "xAI reports the model is at capacity (retries exhausted). Try again in a few minutes or switch models with /model."
2402 );
2403 for banned in ["high demand", "docs.x.ai", "service tier", "Codex"] {
2404 assert!(
2405 !failure.message.contains(banned),
2406 "provider text `{banned}` leaked: {}",
2407 failure.message
2408 );
2409 }
2410 assert!(text.is_empty());
2411 assert!(!decoder.emitted_any_output());
2412 assert!(rx.try_recv().is_err(), "no events for an error-only stream");
2413 }
2414
2415 #[test]
2418 fn flat_code_classifies_capacity_only_for_vetted_codes() {
2419 let capacity = [
2420 r#"data: {"type":"error","code":"rate_limit_exceeded","message":"private"}"#,
2421 "",
2422 ];
2423 let (decoder, _, _) = drive(&capacity);
2424 let failure = decoder.terminal_result().expect_err("must fail");
2425 assert_eq!(failure.code, "responses_capacity");
2426 assert!(failure.is_retryable());
2427
2428 let other = [
2429 r#"data: {"type":"error","code":"invalid_api_key","message":"private"}"#,
2430 "",
2431 ];
2432 let (decoder, _, _) = drive(&other);
2433 let failure = decoder.terminal_result().expect_err("must fail");
2434 assert_eq!(failure.code, "responses_failed");
2435 assert!(!failure.is_retryable());
2436 assert!(!failure.message.contains("private"));
2437 }
2438
2439 #[test]
2440 fn provider_label_is_carried_into_every_terminal_message() {
2441 let (tx, _rx) = mpsc::unbounded_channel();
2442 let mut text = String::new();
2443 let mut decoder = CodexSseDecoder::for_provider("xAI");
2444 decoder.push_line(
2445 r#"data: {"type":"response.completed","response":{"usage":{"input_tokens":1,"output_tokens":0}}}"#,
2446 &tx,
2447 &mut text,
2448 );
2449 decoder.push_line("", &tx, &mut text);
2450 let failure = decoder.terminal_result().expect_err("empty");
2451 assert_eq!(failure.code, "responses_empty");
2452 assert!(failure.is_retryable());
2453 assert_eq!(
2454 failure.message,
2455 "xAI completed without text or tool output. Retry the request."
2456 );
2457
2458 let decoder = CodexSseDecoder::for_provider("xAI");
2459 let failure = decoder.terminal_result().expect_err("missing terminal");
2460 assert_eq!(failure.code, "responses_missing_terminal");
2461 assert!(failure.is_retryable());
2462 assert!(failure.message.starts_with("xAI response stream ended"));
2463
2464 let mut decoder = CodexSseDecoder::for_provider("xAI");
2465 decoder.push_line(
2466 r#"data: {"type":"response.incomplete","response":{"incomplete_details":{"reason":"max_output_tokens"}}}"#,
2467 &tx,
2468 &mut text,
2469 );
2470 decoder.push_line("", &tx, &mut text);
2471 let failure = decoder.terminal_result().expect_err("incomplete");
2472 assert_eq!(failure.code, "responses_incomplete");
2473 assert!(!failure.is_retryable());
2474 assert!(failure.message.starts_with("xAI response was incomplete."));
2475 }
2476
2477 #[test]
2478 fn done_marker_finishes_decoder() {
2479 let lines = [
2480 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_x","name":"bash"}}"#,
2481 "",
2482 r#"data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"{}"}"#,
2483 "",
2484 "data: [DONE]",
2485 "",
2486 ];
2487 let (decoder, _text, _events) = drive(&lines);
2488 assert_eq!(decoder.completed_tools.len(), 1);
2490 assert_eq!(decoder.completed_tools[0].id, "call_x");
2491 assert_eq!(decoder.completed_tools[0].function.arguments, "{}");
2492 }
2493
2494 #[test]
2495 fn finish_is_idempotent_no_double_emit() {
2496 let lines = [
2497 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_y","name":"bash","arguments":"{}"}}"#,
2498 "",
2499 ];
2500 let (mut decoder, _text, _events) = drive(&lines);
2501 assert_eq!(decoder.completed_tools.len(), 1);
2502
2503 decoder.finish();
2505 assert_eq!(
2506 decoder.completed_tools.len(),
2507 1,
2508 "finish() called twice must not double-emit"
2509 );
2510 }
2511
2512 #[test]
2513 fn finish_drains_active_tools_for_state_hygiene() {
2514 let lines = [
2519 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_z","name":"bash"}}"#,
2520 "",
2521 "data: [DONE]",
2522 "",
2523 ];
2524 let (decoder, _text, _events) = drive(&lines);
2525 assert_eq!(decoder.completed_tools.len(), 1);
2526 assert!(
2527 decoder.active_tools.is_empty(),
2528 "active_tools must be drained after finish()"
2529 );
2530 }
2531
2532 #[test]
2533 fn unknown_event_types_are_ignored() {
2534 let lines = [
2535 r#"data: {"type":"response.future_unknown_event","payload":{"x":1}}"#,
2536 "",
2537 r#"data: {"type":"response.output_text.delta","delta":"hi"}"#,
2538 "",
2539 ];
2540 let (_decoder, text_acc, _events) = drive(&lines);
2541 assert_eq!(text_acc, "hi");
2542 }
2543}
2544
2545#[cfg(test)]
2546mod broker_stream_tests {
2547 use super::*;
2553 use crate::auth::{CredentialBroker, LocalBroker};
2554 use std::sync::Arc;
2555
2556 async fn spawn_fake_openai_sse() -> (String, Arc<std::sync::Mutex<String>>) {
2557 use axum::routing::post;
2558 let seen_auth = Arc::new(std::sync::Mutex::new(String::new()));
2559 let seen = seen_auth.clone();
2560 let app = axum::Router::new().route(
2561 "/chat/completions",
2562 post(move |headers: axum::http::HeaderMap| {
2563 let seen = seen.clone();
2564 async move {
2565 *seen.lock().unwrap() = headers
2566 .get("authorization")
2567 .and_then(|v| v.to_str().ok())
2568 .unwrap_or("")
2569 .to_string();
2570 let body = concat!(
2571 "data: {\"choices\":[{\"delta\":{\"content\":\"Hel\"}}]}\n\n",
2572 "data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}\n\n",
2573 "data: [DONE]\n\n",
2574 );
2575 ([("content-type", "text/event-stream")], body)
2576 }
2577 }),
2578 );
2579 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2580 let addr = listener.local_addr().unwrap();
2581 tokio::spawn(async move {
2582 axum::serve(listener, app).await.unwrap();
2583 });
2584 (format!("http://{addr}"), seen_auth)
2585 }
2586
2587 #[tokio::test]
2588 async fn oai_stream_flows_through_broker_and_forwards_deltas() {
2589 let (upstream, seen_auth) = spawn_fake_openai_sse().await;
2590 let broker: Arc<dyn CredentialBroker> = Arc::new(LocalBroker::with_local_base_url(
2591 reqwest::Client::new(),
2592 upstream,
2593 ));
2594 let cfg = ProviderConfig {
2595 base_url: "unused-broker-derives-the-url".to_string(),
2596 model: "test-model".to_string(),
2597 provider: "local".to_string(),
2598 };
2599 let (tx, mut rx) = mpsc::unbounded_channel::<StreamEvent>();
2600 let cancel = tokio_util::sync::CancellationToken::new();
2601
2602 let result = call_oai_stream_inner(
2603 &cfg,
2604 &broker,
2605 &[],
2606 &None,
2607 &[],
2608 &tx,
2609 None,
2610 None,
2611 0,
2612 agent_core::reasoning::ReasoningLevel::Medium,
2613 &cancel,
2614 &crate::runtime::trace::TraceContext::disabled(),
2615 true,
2616 false,
2617 0,
2618 )
2619 .await
2620 .expect("stream must complete");
2621
2622 assert_eq!(result["content"][0]["text"], "Hello");
2624
2625 drop(tx);
2627 let mut streamed = String::new();
2628 while let Ok(ev) = rx.try_recv() {
2629 if let StreamEvent::Llm(crate::runtime::types::LlmEvent::Text(t)) = ev {
2630 streamed.push_str(&t);
2631 }
2632 }
2633 assert_eq!(streamed, "Hello");
2634
2635 assert_eq!(&*seen_auth.lock().unwrap(), "Bearer local");
2637 }
2638
2639 #[tokio::test]
2642 async fn suppressed_sync_route_does_not_enqueue_display_deltas() {
2643 let (upstream, _seen_auth) = spawn_fake_openai_sse().await;
2644 let broker: Arc<dyn CredentialBroker> = Arc::new(LocalBroker::with_local_base_url(
2645 reqwest::Client::new(),
2646 upstream,
2647 ));
2648 let cfg = ProviderConfig {
2649 base_url: "unused-broker-derives-the-url".to_string(),
2650 model: "test-model".to_string(),
2651 provider: "local".to_string(),
2652 };
2653 let (tx, mut rx) = mpsc::unbounded_channel::<StreamEvent>();
2654 let result = call_oai_stream_inner(
2655 &cfg,
2656 &broker,
2657 &[],
2658 &None,
2659 &[],
2660 &tx,
2661 None,
2662 None,
2663 0,
2664 agent_core::reasoning::ReasoningLevel::Medium,
2665 &tokio_util::sync::CancellationToken::new(),
2666 &crate::runtime::trace::TraceContext::disabled(),
2667 true,
2668 true,
2669 0,
2670 )
2671 .await
2672 .expect("stream must complete");
2673 assert_eq!(result["content"][0]["text"], "Hello");
2674 assert!(
2675 rx.try_recv().is_err(),
2676 "sync route must suppress display deltas at production"
2677 );
2678 }
2679
2680 #[tokio::test]
2681 async fn oai_stream_broker_error_fails_closed() {
2682 let broker: Arc<dyn CredentialBroker> = Arc::new(LocalBroker::new(reqwest::Client::new()));
2684 let cfg = ProviderConfig {
2685 base_url: String::new(),
2686 model: "m".to_string(),
2687 provider: "definitely-not-a-provider".to_string(),
2688 };
2689 let (tx, _rx) = mpsc::unbounded_channel::<StreamEvent>();
2690 let cancel = tokio_util::sync::CancellationToken::new();
2691 let err = call_oai_stream_inner(
2692 &cfg,
2693 &broker,
2694 &[],
2695 &None,
2696 &[],
2697 &tx,
2698 None,
2699 None,
2700 0,
2701 agent_core::reasoning::ReasoningLevel::Medium,
2702 &cancel,
2703 &crate::runtime::trace::TraceContext::disabled(),
2704 true,
2705 false,
2706 0,
2707 )
2708 .await
2709 .unwrap_err()
2710 .to_string();
2711 assert!(err.contains("unknown provider"), "got: {err}");
2712 assert!(!err.to_lowercase().contains("bearer"));
2713 }
2714
2715 #[tokio::test]
2720 async fn oai_stream_upstream_error_body_never_surfaces() {
2721 use axum::response::IntoResponse;
2722 const SENTINEL: &str = "PH1-OAI-BROKER-SENTINEL-91d2-RAW";
2723 let app = axum::Router::new().route(
2724 "/chat/completions",
2725 axum::routing::post(move |body: String| async move {
2726 (
2727 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
2728 [("content-type", "application/json")],
2729 format!("{{\"error\":{{\"message\":\"ECHOED:{body}\"}}}}"),
2730 )
2731 .into_response()
2732 }),
2733 );
2734 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2735 let addr = listener.local_addr().unwrap();
2736 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
2737
2738 let broker: Arc<dyn CredentialBroker> = Arc::new(LocalBroker::with_local_base_url(
2739 reqwest::Client::new(),
2740 format!("http://{addr}"),
2741 ));
2742 let cfg = ProviderConfig {
2743 base_url: "unused-broker-derives-the-url".to_string(),
2744 model: "test-model".to_string(),
2745 provider: "local".to_string(),
2746 };
2747 let tools = vec![serde_json::json!({
2748 "name": "ph1_secret_tool_zz",
2749 "description": "internal tool schema",
2750 "input_schema": {"type": "object", "properties": {}}
2751 })];
2752 let msgs: Vec<crate::SharedMessage> = vec![Arc::new(
2753 serde_json::json!({"role": "user", "content": SENTINEL}),
2754 )];
2755 let (tx, _rx) = mpsc::unbounded_channel::<StreamEvent>();
2756 let cancel = tokio_util::sync::CancellationToken::new();
2757
2758 let err = call_oai_stream_inner(
2759 &cfg,
2760 &broker,
2761 &tools,
2762 &Some(format!("system secret {SENTINEL}")),
2763 &msgs,
2764 &tx,
2765 None,
2766 None,
2767 0,
2768 agent_core::reasoning::ReasoningLevel::Medium,
2769 &cancel,
2770 &crate::runtime::trace::TraceContext::disabled(),
2771 true,
2772 false,
2773 0,
2774 )
2775 .await
2776 .expect_err("500 must fail")
2777 .to_string();
2778
2779 assert!(err.contains("500"), "status must survive: {err}");
2780 for banned in ["ECHOED", SENTINEL, "ph1_secret_tool_zz", "system secret"] {
2781 assert!(
2782 !err.contains(banned),
2783 "provider body content `{banned}` leaked into the surfaced error: {err}"
2784 );
2785 }
2786 }
2787}
2788
2789const XAI_PROVIDER_LABEL: &str = "xAI";
2791
2792#[allow(clippy::too_many_arguments)]
2793pub(crate) async fn call_xai_responses_stream_inner(
2794 cfg: &ProviderConfig,
2795 broker: &std::sync::Arc<dyn crate::auth::CredentialBroker>,
2796 tools_schema: &[Value],
2797 system_prompt: &Option<String>,
2798 messages: &[crate::SharedMessage],
2799 tx: &mpsc::UnboundedSender<StreamEvent>,
2800 max_tokens: Option<u32>,
2801 reasoning_level: agent_core::reasoning::ReasoningLevel,
2802 cancel: &tokio_util::sync::CancellationToken,
2803 max_retries: u32,
2804 trace: &crate::runtime::trace::TraceContext,
2805 exact_wire_bytes: bool,
2806) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
2807 let (tools, names) = translate::tools_to_responses(tools_schema);
2808 let input = codex_input_messages(translate::messages_to_oai(messages, system_prompt, &names));
2809 let body = build_xai_body(
2812 &cfg.model,
2813 reasoning_level,
2814 serde_json::to_value(input)?,
2815 tools,
2816 max_tokens,
2817 )
2818 .map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
2819 let (proxy_request, body_bytes) =
2827 crate::auth::ProxyRequest::post_json_exact("xai-auth", "/responses", body, true)?;
2828 let tracer = tr::begin_openai_tracer(
2829 trace,
2830 &cfg.provider,
2831 &cfg.model,
2832 if exact_wire_bytes {
2833 crate::runtime::trace::TransportKind::OpenAiResponses
2834 } else {
2835 crate::runtime::trace::TransportKind::CloudProxy
2836 },
2837 &format!("{}/responses", cfg.base_url.trim_end_matches('/')),
2838 exact_wire_bytes.then_some(body_bytes.as_ref()),
2839 messages,
2840 system_prompt.as_deref(),
2841 tools_schema,
2842 tr::renamed_tool_losses(&names),
2843 )
2844 .await;
2845 if let Some(tracer) = &tracer {
2851 tr::capture_request_content(trace, tracer.request_id(), body_bytes.as_ref(), messages);
2852 }
2853 let mut attempt = tr::StreamAttempt::new(tracer);
2854 let mut stream_retry = 0u32;
2855
2856 loop {
2857 if cancel.is_cancelled() {
2858 return Err("request canceled".into());
2859 }
2860 let stream = crate::runtime::api::await_or_cancel(
2861 cancel,
2862 broker.proxy_stream(proxy_request.clone()),
2863 )
2864 .await
2865 .map_err(|_| "request canceled")?
2866 .map_err(|e| super::net::redact_provider_proxy_error(&e.to_string()));
2869 let mut stream = match stream {
2870 Ok(stream) => {
2871 attempt.mark_headers();
2872 stream
2873 }
2874 Err(msg) => {
2875 let status = tr::broker_error_status(&msg);
2876 let code =
2877 status.map_or_else(|| "broker_error".to_string(), |s| format!("http_{s}"));
2878 attempt.finish_failed(&code, status, None);
2879 return Err(super::net::normalize_broker_http_error(msg).into());
2880 }
2881 };
2882 let _ = tx.send(StreamEvent::Llm(crate::LlmEvent::ResponseStart));
2883 let mut interrupted = false;
2884 let mut text = String::new();
2885 let mut parser = CodexSseDecoder::for_provider(XAI_PROVIDER_LABEL);
2886 let mut buf = bytes::BytesMut::new();
2887 while let Some(chunk) = tokio::select! {
2888 biased;
2889 _ = cancel.cancelled() => {
2890 attempt.finish_canceled(None, parser.trace_usage());
2891 return Err("request canceled".into());
2892 }
2893 c = stream.next() => c,
2894 } {
2895 let chunk = match chunk {
2896 Ok(chunk) => {
2897 attempt.mark_first_byte();
2898 chunk
2899 }
2900 Err(e) => {
2901 if !matches!(e, crate::auth::BrokerError::Transport(_)) {
2902 attempt.finish_failed("broker_error", None, None);
2903 return Err(e.into());
2904 }
2905 interrupted = !parser.terminal_success && parser.terminal_failure.is_none();
2906 break;
2907 }
2908 };
2909 buf.extend_from_slice(&chunk);
2910 while let Some(n) = memchr::memchr(b'\n', &buf) {
2911 let line = buf.split_to(n + 1);
2912 parser.push_line(std::str::from_utf8(&line[..n]).unwrap_or(""), tx, &mut text);
2913 }
2914 if parser.saw_model_event {
2915 attempt.mark_first_model_event();
2916 }
2917 }
2918 if !interrupted && !buf.is_empty() {
2919 parser.push_line(std::str::from_utf8(&buf).unwrap_or(""), tx, &mut text);
2920 }
2921 if interrupted || (!parser.terminal_success && parser.terminal_failure.is_none()) {
2922 if stream_retry >= max_retries {
2923 attempt.finish_failed("stream_error", None, None);
2924 return Err(STREAM_INTERRUPTED.into());
2925 }
2926 stream_retry += 1;
2927 attempt.attempt_failed(
2928 crate::runtime::trace::RetryClass::Network,
2929 retry_delay(stream_retry),
2930 None,
2931 None,
2932 "stream_error",
2933 );
2934 wait_stream_retry(tx, cancel, stream_retry, max_retries, false)
2935 .await
2936 .inspect_err(|_| {
2937 attempt.finish_canceled(None, None);
2938 })?;
2939 attempt.restart_clock();
2940 continue;
2941 }
2942 parser.finish();
2943 if parser.saw_model_event {
2947 attempt.mark_first_model_event();
2948 }
2949 if let Err(failure) = parser.terminal_result() {
2953 let output_already_streamed = !text.is_empty() || parser.emitted_any_output();
2959 if failure.is_retryable() && !output_already_streamed && stream_retry < max_retries {
2960 stream_retry += 1;
2961 let delay = retry_delay(stream_retry);
2962 attempt.attempt_failed(
2963 crate::runtime::trace::RetryClass::Other,
2964 delay,
2965 None,
2966 None,
2967 failure.code,
2968 );
2969 tracing::warn!(
2970 code = failure.code,
2971 "xai stream retry {stream_retry}/{max_retries} after {delay:?}"
2972 );
2973 tokio::select! {
2974 _ = tokio::time::sleep(delay) => {}
2975 _ = cancel.cancelled() => {
2976 attempt.finish_canceled(None, None);
2977 return Err("request canceled".into());
2978 }
2979 }
2980 attempt.restart_clock();
2981 continue;
2982 }
2983 attempt.finish_failed(failure.code, None, None);
2984 return Err(failure.message.into());
2985 }
2986 attempt.finish_success(None, None, None, parser.trace_usage());
2987 let mut content = Vec::new();
2988 if !text.is_empty() {
2989 content.push(json!({"type":"text","text":text}));
2990 }
2991 content.extend(translate::tool_calls_to_content_blocks(
2992 &parser.completed_tools,
2993 &names,
2994 ));
2995 return Ok(json!({"role":"assistant","content":content}));
2996 }
2997}
2998
2999pub(crate) fn build_xai_body(
3010 model: &str,
3011 level: agent_core::reasoning::ReasoningLevel,
3012 input: Value,
3013 tools: Vec<Value>,
3014 max_tokens: Option<u32>,
3015) -> Result<Value, String> {
3016 use agent_core::reasoning::ReasoningLevel;
3017 crate::runtime::openai::catalog::validation::validate_reasoning_mutation(
3018 &format!("xai-auth/{model}"),
3019 level,
3020 )?;
3021 let mut body = serde_json::Map::new();
3022 body.insert("model".into(), json!(model));
3023 body.insert("input".into(), input);
3024 body.insert("stream".into(), json!(true));
3025 if !tools.is_empty() {
3026 body.insert("tools".into(), Value::Array(tools));
3027 }
3028 if let Some(max) = max_tokens {
3029 body.insert("max_output_tokens".into(), json!(max));
3030 }
3031 match level {
3032 ReasoningLevel::Off | ReasoningLevel::Adaptive => {}
3035 l => {
3036 body.insert("reasoning".into(), json!({ "effort": l.as_str() }));
3037 }
3038 }
3039 Ok(Value::Object(body))
3040}
3041
3042#[cfg(test)]
3043mod xai_tests {
3044 use super::*;
3045 use agent_core::reasoning::ReasoningLevel;
3046
3047 #[test]
3048 fn xai_fixture_is_public_responses_shape() {
3049 let fixture = serde_json::json!({"type":"response.output_text.delta","delta":"hello"});
3050 assert_eq!(fixture["type"], "response.output_text.delta");
3051 assert_eq!(fixture["delta"], "hello");
3052 }
3053
3054 fn body_for(model: &str, level: ReasoningLevel) -> Result<Value, String> {
3055 build_xai_body(model, level, json!([]), Vec::new(), Some(1024))
3056 }
3057
3058 #[test]
3061 fn grok45_emits_exact_documented_efforts() {
3062 for (level, effort) in [
3063 (ReasoningLevel::Low, "low"),
3064 (ReasoningLevel::Medium, "medium"),
3065 (ReasoningLevel::High, "high"),
3066 ] {
3067 let body = body_for("grok-4.5", level).expect("supported effort");
3068 assert_eq!(body["reasoning"], json!({"effort": effort}), "{level}");
3069 assert_eq!(body["model"], "grok-4.5");
3070 assert_eq!(body["stream"], json!(true));
3071 assert_eq!(body["max_output_tokens"], json!(1024));
3072 }
3073 }
3074
3075 #[test]
3076 fn grok47_responses_wire_uses_exact_efforts_and_preserves_tool_shape() {
3077 for (level, effort) in [
3078 (ReasoningLevel::Low, "low"),
3079 (ReasoningLevel::Medium, "medium"),
3080 (ReasoningLevel::High, "high"),
3081 (ReasoningLevel::XHigh, "xhigh"),
3082 ] {
3083 let input = json!([{"role": "user", "content": "hello"}]);
3084 let tool = json!({"type": "function", "name": "inspect", "parameters": {"type": "object"}});
3085 let body = build_xai_body("grok-4.7", level, input.clone(), vec![tool.clone()], Some(1024)).unwrap();
3086 assert_eq!(body["model"], "grok-4.7");
3087 assert_eq!(body["reasoning"], json!({"effort": effort}));
3088 assert_eq!(body["input"], input);
3089 assert_eq!(body["tools"], json!([tool]));
3090 assert_eq!(body["stream"], true);
3091 assert_eq!(body["max_output_tokens"], 1024);
3092 assert!(body.get("messages").is_none());
3093 assert!(body.get("reasoning_effort").is_none());
3094 assert!(body.get("include").is_none());
3097 assert!(body.get("store").is_none());
3098 }
3099 assert!(body_for("grok-4.7", ReasoningLevel::Adaptive).unwrap().get("reasoning").is_none());
3100 for level in [ReasoningLevel::Off, ReasoningLevel::Max, ReasoningLevel::Ultra, ReasoningLevel::UltraCode] {
3101 assert!(body_for("grok-4.7", level).is_err(), "{level}");
3102 }
3103 }
3104
3105 #[test]
3106 fn grok46_emits_exact_documented_efforts() {
3107 for (level, effort) in [
3108 (ReasoningLevel::Low, "low"),
3109 (ReasoningLevel::Medium, "medium"),
3110 (ReasoningLevel::High, "high"),
3111 ] {
3112 let body = body_for("grok-4.6", level).expect("supported effort");
3113 assert_eq!(body["reasoning"], json!({"effort": effort}), "{level}");
3114 assert_eq!(body["model"], "grok-4.6");
3115 assert_eq!(body["stream"], json!(true));
3116 assert_eq!(body["max_output_tokens"], json!(1024));
3117 }
3118 }
3119
3120 #[test]
3121 fn adaptive_omits_reasoning_field_provider_default() {
3122 for model in [
3123 "grok-4.5",
3124 "grok-4.5-latest",
3125 "grok-4.6",
3126 "grok-4.3",
3127 "grok-4.20-0309-non-reasoning",
3128 ] {
3129 let body = body_for(model, ReasoningLevel::Adaptive).expect("adaptive is omission");
3130 assert!(body.get("reasoning").is_none(), "{model}");
3131 }
3132 }
3133
3134 #[test]
3135 fn off_is_rejected_pre_network_when_reasoning_cannot_be_disabled() {
3136 for model in [
3137 "grok-4.5",
3138 "grok-4.5-latest",
3139 "grok-4.6",
3140 "grok-4.20-multi-agent-0309",
3141 "grok-4.3",
3142 ] {
3143 let err = body_for(model, ReasoningLevel::Off).unwrap_err();
3144 assert!(err.contains(model), "{model}: {err}");
3145 }
3146 let body = body_for("grok-4.20-0309-non-reasoning", ReasoningLevel::Off).unwrap();
3148 assert!(body.get("reasoning").is_none());
3149 }
3150
3151 #[test]
3152 fn unsupported_named_efforts_are_rejected_pre_network() {
3153 for level in [
3155 ReasoningLevel::XHigh,
3156 ReasoningLevel::Max,
3157 ReasoningLevel::Ultra,
3158 ] {
3159 assert!(body_for("grok-4.5", level).is_err(), "{level}");
3160 assert!(body_for("grok-4.6", level).is_err(), "{level}");
3161 }
3162 assert!(body_for("grok-4.3", ReasoningLevel::Medium).is_err());
3164 assert!(body_for("grok-4.20-0309-non-reasoning", ReasoningLevel::Medium).is_err());
3166 assert!(body_for("grok-9000", ReasoningLevel::Medium).is_err());
3168 }
3169
3170 #[test]
3171 fn multi_agent_xhigh_is_exact_agent_count_control() {
3172 let body = body_for("grok-4.20-multi-agent-0309", ReasoningLevel::XHigh).unwrap();
3173 assert_eq!(body["reasoning"], json!({"effort": "xhigh"}));
3174 }
3175
3176 #[test]
3177 fn responses_wire_shape_tools_flat_and_no_chat_completions_fields() {
3178 let tools = vec![json!({
3179 "type": "function",
3180 "name": "get_weather",
3181 "description": "d",
3182 "parameters": {"type": "object"}
3183 })];
3184 let body =
3185 build_xai_body("grok-4.5", ReasoningLevel::High, json!([]), tools, None).unwrap();
3186 assert_eq!(body["tools"][0]["name"], "get_weather");
3189 assert!(body["tools"][0].get("function").is_none());
3190 assert!(body.get("input").is_some());
3191 assert!(body.get("messages").is_none());
3192 assert!(body.get("max_output_tokens").is_none());
3193 }
3194}
3195
3196#[cfg(test)]
3197mod xai_capacity_retry_tests {
3198 use super::*;
3207 use agent_core::auth::{
3208 AccessToken, BrokerError, ProxyByteStream, ProxyRequest, ProxyResponse,
3209 };
3210 use async_trait::async_trait;
3211 use std::sync::{
3212 atomic::{AtomicUsize, Ordering},
3213 Arc,
3214 };
3215
3216 const XAI_CAPACITY_SSE: &str = "data: {\"sequence_number\":0,\"type\":\"error\",\"code\":null,\"message\":\"The model is currently at capacity due to high demand. Please try again in a few minutes, or use a higher service tier for priority processing: https://docs.x.ai/developers/advanced-api-usage/priority-processing\",\"param\":null}\n\n";
3219
3220 const XAI_SUCCESS_SSE: &str = concat!(
3221 "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\"}}\n\n",
3222 "data: {\"type\":\"response.output_text.delta\",\"delta\":\"GROK_OK\"}\n\n",
3223 "data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":7,\"output_tokens\":2}}}\n\n",
3224 );
3225
3226 struct ScriptedXaiBroker {
3229 bodies: Vec<&'static str>,
3230 calls: Arc<AtomicUsize>,
3231 seen: Arc<std::sync::Mutex<Vec<ProxyRequest>>>,
3232 }
3233
3234 #[async_trait]
3235 impl crate::auth::CredentialBroker for ScriptedXaiBroker {
3236 async fn access_token(
3237 &self,
3238 _p: agent_core::auth::OAuthProviderId,
3239 ) -> Result<AccessToken, BrokerError> {
3240 Err(BrokerError::Denied(
3241 "xai route must use proxy_stream".into(),
3242 ))
3243 }
3244 async fn proxy(&self, _request: ProxyRequest) -> Result<ProxyResponse, BrokerError> {
3245 Err(BrokerError::Denied("not implemented in stub".into()))
3246 }
3247 async fn proxy_stream(
3248 &self,
3249 request: ProxyRequest,
3250 ) -> Result<ProxyByteStream, BrokerError> {
3251 request.validate()?;
3252 let n = self.calls.fetch_add(1, Ordering::SeqCst);
3253 self.seen.lock().unwrap().push(request);
3254 let body = self.bodies[n.min(self.bodies.len() - 1)];
3255 Ok(Box::pin(futures::stream::iter(vec![Ok(
3256 bytes::Bytes::from(body),
3257 )])))
3258 }
3259 async fn anthropic_usage(&self) -> Result<serde_json::Value, BrokerError> {
3260 Err(BrokerError::Denied("not implemented in stub".into()))
3261 }
3262 async fn capabilities(&self) -> Result<Vec<agent_core::auth::ProviderStatus>, BrokerError> {
3263 Ok(vec![])
3264 }
3265 }
3266
3267 struct Run {
3268 result: Result<Value, Box<dyn std::error::Error + Send + Sync>>,
3269 calls: usize,
3270 seen: Vec<ProxyRequest>,
3271 events: Vec<StreamEvent>,
3272 }
3273
3274 async fn run_xai(bodies: Vec<&'static str>, max_retries: u32) -> Run {
3275 let calls = Arc::new(AtomicUsize::new(0));
3276 let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
3277 let broker: Arc<dyn crate::auth::CredentialBroker> = Arc::new(ScriptedXaiBroker {
3278 bodies,
3279 calls: Arc::clone(&calls),
3280 seen: Arc::clone(&seen),
3281 });
3282 let cfg = ProviderConfig {
3283 base_url: "https://api.x.ai/v1".to_string(),
3284 model: "grok-4.5".to_string(),
3285 provider: "xai-auth".to_string(),
3286 };
3287 let (tx, mut rx) = mpsc::unbounded_channel::<StreamEvent>();
3288 let result = call_xai_responses_stream_inner(
3289 &cfg,
3290 &broker,
3291 &[],
3292 &Some("system".to_string()),
3293 &[],
3294 &tx,
3295 None,
3296 agent_core::reasoning::ReasoningLevel::Adaptive,
3297 &tokio_util::sync::CancellationToken::new(),
3298 max_retries,
3299 &crate::runtime::trace::TraceContext::disabled(),
3300 true,
3301 )
3302 .await;
3303 drop(tx);
3304 let mut events = Vec::new();
3305 while let Ok(event) = rx.try_recv() {
3306 events.push(event);
3307 }
3308 let seen = std::mem::take(&mut *seen.lock().unwrap());
3309 Run {
3310 result,
3311 calls: calls.load(Ordering::SeqCst),
3312 seen,
3313 events,
3314 }
3315 }
3316
3317 fn text_events(events: &[StreamEvent]) -> Vec<String> {
3318 events
3319 .iter()
3320 .filter_map(|e| match e {
3321 StreamEvent::Llm(crate::runtime::types::LlmEvent::Text(t)) => Some(t.clone()),
3322 _ => None,
3323 })
3324 .collect()
3325 }
3326
3327 #[tokio::test(start_paused = true)]
3328 async fn xai_capacity_error_is_retried_with_identical_bytes_then_succeeds() {
3329 let run = run_xai(vec![XAI_CAPACITY_SSE, XAI_SUCCESS_SSE], 3).await;
3330 let value = run.result.expect("second attempt must succeed");
3331 assert_eq!(value["content"][0]["text"], "GROK_OK");
3332 assert_eq!(run.calls, 2, "exactly one retry");
3333 assert_eq!(run.seen.len(), 2);
3334 assert_eq!(
3335 run.seen[0].body_bytes, run.seen[1].body_bytes,
3336 "retry must re-send the identical serialized body"
3337 );
3338 assert_eq!(run.seen[0].body, run.seen[1].body);
3339 assert_eq!(run.seen[0].provider, "xai-auth");
3340 assert_eq!(run.seen[0].path, "/responses");
3341 assert_eq!(text_events(&run.events), vec!["GROK_OK".to_string()]);
3343 }
3344
3345 #[tokio::test(start_paused = true)]
3346 async fn xai_capacity_error_with_zero_retries_surfaces_xai_capacity_message() {
3347 let run = run_xai(vec![XAI_CAPACITY_SSE], 0).await;
3348 let err = run.result.expect_err("no retry budget → terminal");
3349 let msg = err.to_string();
3350 assert_eq!(
3351 msg,
3352 "xAI reports the model is at capacity (retries exhausted). Try again in a few minutes or switch models with /model."
3353 );
3354 assert_eq!(run.calls, 1);
3355 for banned in ["Codex", "high demand", "docs.x.ai", "service tier"] {
3356 assert!(!msg.contains(banned), "`{banned}` leaked: {msg}");
3357 }
3358 assert!(matches!(
3360 super::super::net::provider_error_to_runtime(err),
3361 crate::RuntimeError::ApiStatus(_)
3362 ));
3363 }
3364
3365 #[tokio::test(start_paused = true)]
3366 async fn xai_capacity_error_exhausting_budget_counts_every_attempt() {
3367 let run = run_xai(vec![XAI_CAPACITY_SSE], 2).await;
3368 let err = run.result.expect_err("persistent capacity → terminal");
3369 assert!(err
3370 .to_string()
3371 .starts_with("xAI reports the model is at capacity"));
3372 assert_eq!(run.calls, 3, "initial attempt + 2 retries");
3373 assert!(text_events(&run.events).is_empty());
3374 }
3375
3376 #[tokio::test(start_paused = true)]
3379 async fn xai_does_not_retry_after_partial_text_was_streamed() {
3380 const PARTIAL_THEN_ERROR: &str = concat!(
3381 "data: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\n\n",
3382 "data: {\"type\":\"error\",\"code\":null,\"message\":\"at capacity\"}\n\n",
3383 );
3384 let run = run_xai(vec![PARTIAL_THEN_ERROR, XAI_SUCCESS_SSE], 3).await;
3385 let err = run.result.expect_err("must not retry after partial output");
3386 assert!(err
3387 .to_string()
3388 .starts_with("xAI reports the model is at capacity"));
3389 assert_eq!(run.calls, 1, "no retry once output reached the UI");
3390 assert_eq!(text_events(&run.events), vec!["partial".to_string()]);
3391 }
3392
3393 #[tokio::test(start_paused = true)]
3395 async fn xai_retries_eof_after_tool_preview_with_reset() {
3396 const TOOL_THEN_EOF: &str = "data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"function_call\",\"call_id\":\"call_1\",\"name\":\"bash\"}}\n\n";
3397 let run = run_xai(vec![TOOL_THEN_EOF, XAI_SUCCESS_SSE], 3).await;
3398 assert_eq!(run.result.unwrap()["content"][0]["text"], "GROK_OK");
3399 assert_eq!(run.calls, 2);
3400 assert_eq!(run.seen[0].body_bytes, run.seen[1].body_bytes);
3401 assert!(run
3402 .events
3403 .iter()
3404 .any(|e| matches!(e, StreamEvent::Llm(crate::LlmEvent::ResponseReset))));
3405 }
3406
3407 #[test]
3408 fn responses_exact_account_errors_survive_decoder_net_and_driver() {
3409 for (code, suffix, expected) in [
3410 ("invalid_api_key", RESPONSES_AUTH_SUFFIX, "auth"),
3411 ("authentication_error", RESPONSES_AUTH_SUFFIX, "auth"),
3412 (
3413 "invalid_authentication_error",
3414 RESPONSES_AUTH_SUFFIX,
3415 "auth",
3416 ),
3417 ("insufficient_quota", RESPONSES_QUOTA_SUFFIX, "quota"),
3418 ("quota_exceeded", RESPONSES_QUOTA_SUFFIX, "quota"),
3419 ("billing_error", RESPONSES_QUOTA_SUFFIX, "quota"),
3420 ] {
3421 for label in ["Codex", "OpenAI", "xAI"] {
3422 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3423 let mut decoder = CodexSseDecoder::for_provider(label);
3424 let frame = format!(
3425 "data: {}\n\n",
3426 json!({"type":"response.failed", "response":{
3427 "error":{"code":code,"message":"SECRET at capacity"}
3428 }})
3429 );
3430 let mut text = String::new();
3431 for line in frame.lines() {
3432 decoder.push_line(line, &tx, &mut text);
3433 }
3434 let failure = decoder.terminal_result().unwrap_err();
3435 assert_eq!(failure.message, format!("{label}{suffix}"));
3436 assert!(!failure.is_retryable());
3437 let runtime = super::super::net::provider_error_to_runtime(failure.message.into());
3438 assert!(matches!(runtime, crate::RuntimeError::ApiStatus(_)));
3439 let typed = crate::runtime::helpers::turn_error_for(&runtime, "test");
3440 assert_eq!(
3441 crate::extensions::session_driver::classify_turn_error(&typed),
3442 (
3443 crate::extensions::session_driver::Outcome::ProviderError,
3444 expected.into()
3445 )
3446 );
3447 assert!(!typed.message.contains("SECRET"));
3448 }
3449 }
3450 for code in [
3451 "permission_denied",
3452 "context_length_exceeded",
3453 "invalid_request_error",
3454 "unknown",
3455 ] {
3456 let message = user_message_for_terminal_failure("Codex", "absent", code, false);
3457 assert_eq!(
3458 crate::extensions::session_driver::classify_error(&message).0,
3459 crate::extensions::session_driver::Outcome::Blocked
3460 );
3461 }
3462 }
3463
3464 #[test]
3465 fn explicit_policy_and_conflicting_error_classes_outrank_capacity_prose() {
3466 for (kind, code) in [
3467 ("absent", "permission_denied"),
3468 ("permission_error", "rate_limit_exceeded"),
3469 ("invalid_request_error", "insufficient_quota"),
3470 ("authentication_error", "quota_exceeded"),
3471 ("server_error", "invalid_api_key"),
3472 ("unknown", "absent"),
3473 ] {
3474 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3475 let mut decoder = CodexSseDecoder::for_provider("Codex");
3476 let frame = format!(
3477 "data: {}",
3478 json!({"type":"response.failed", "response":{
3479 "error":{"type":kind, "code":code, "message":"private policy at capacity"}
3480 }})
3481 );
3482 let mut text = String::new();
3483 decoder.push_line(&frame, &tx, &mut text);
3484 decoder.push_line("", &tx, &mut text);
3485 let failure = decoder.terminal_result().unwrap_err();
3486 assert!(!failure.is_retryable());
3487 assert_eq!(
3488 crate::extensions::session_driver::classify_error(&failure.message).0,
3489 crate::extensions::session_driver::Outcome::Blocked
3490 );
3491 assert!(!failure.message.contains("private"));
3492 }
3493 }
3494
3495 #[tokio::test(start_paused = true)]
3498 async fn xai_non_capacity_error_is_terminal_and_labelled_xai() {
3499 const OTHER_ERROR: &str = "data: {\"type\":\"error\",\"code\":\"invalid_api_key\",\"message\":\"ECHOED:secret prompt\"}\n\n";
3500 let run = run_xai(vec![OTHER_ERROR, XAI_SUCCESS_SSE], 3).await;
3501 let err = run.result.expect_err("must fail");
3502 let msg = err.to_string();
3503 assert_eq!(msg, format!("xAI{RESPONSES_AUTH_SUFFIX}"));
3504 assert!(!msg.contains("ECHOED"));
3505 assert_eq!(run.calls, 1);
3506 }
3507}
3508
3509#[cfg(test)]
3512mod codex_wire_tests {
3513 use crate::runtime::openai::catalog::{
3514 plan_codex_execution, CodexMultiAgentMode, CodexRequestRole,
3515 };
3516 use agent_core::reasoning::ReasoningLevel;
3517
3518 fn plan(
3519 level: ReasoningLevel,
3520 role: CodexRequestRole,
3521 ) -> crate::runtime::openai::catalog::CodexExecutionPlan {
3522 plan_codex_execution("openai-codex/gpt-5.6-sol", level, role, None)
3523 .expect("Sol request plan")
3524 }
3525
3526 #[allow(clippy::too_many_arguments)]
3527 fn body_for_level(
3528 model: &str,
3529 level: ReasoningLevel,
3530 input: serde_json::Value,
3531 instructions: String,
3532 tools: Vec<serde_json::Value>,
3533 temperature: Option<f32>,
3534 max_tokens: Option<u32>,
3535 ) -> serde_json::Value {
3536 let qualified = format!("openai-codex/{model}");
3537 let plan = plan_codex_execution(&qualified, level, CodexRequestRole::Foreground, None)
3538 .expect("request plan");
3539 super::build_codex_body(
3540 model,
3541 &plan,
3542 input,
3543 instructions,
3544 tools,
3545 temperature,
3546 max_tokens,
3547 "synaps-test-cache-key",
3548 )
3549 }
3550
3551 fn codex_effort_for(model_id: &str, level: ReasoningLevel) -> Result<String, String> {
3553 let qualified = format!("openai-codex/{model_id}");
3554 let plan = plan_codex_execution(&qualified, level, CodexRequestRole::Foreground, None)
3555 .map_err(|error| error.to_string())?;
3556 plan.wire_effort
3557 .map(|effort| effort.as_str().to_string())
3558 .ok_or_else(|| "reasoning effort omitted".to_string())
3559 }
3560
3561 #[test]
3562 fn sol_lowers_ultra_to_max_for_requests() {
3563 let effort = codex_effort_for("gpt-5.6-sol", ReasoningLevel::Ultra).unwrap();
3564 assert_eq!(effort, "max");
3565 }
3566
3567 #[test]
3568 fn sol_emits_max_exact() {
3569 let effort = codex_effort_for("gpt-5.6-sol", ReasoningLevel::Max).unwrap();
3570 assert_eq!(effort, "max");
3571 }
3572
3573 #[test]
3574 fn sol_emits_xhigh_exact() {
3575 let effort = codex_effort_for("gpt-5.6-sol", ReasoningLevel::XHigh).unwrap();
3576 assert_eq!(effort, "xhigh");
3577 }
3578
3579 #[test]
3580 fn luna_emits_max_exact() {
3581 let effort = codex_effort_for("gpt-5.6-luna", ReasoningLevel::Max).unwrap();
3582 assert_eq!(effort, "max");
3583 }
3584
3585 #[test]
3586 fn luna_rejects_ultra_before_network() {
3587 let err = codex_effort_for("gpt-5.6-luna", ReasoningLevel::Ultra).unwrap_err();
3589 assert!(err.contains("ultra"), "{err}");
3590 assert!(err.contains("gpt-5.6-luna"), "{err}");
3591 }
3592
3593 #[test]
3594 fn gpt55_emits_xhigh_exact() {
3595 let effort = codex_effort_for("gpt-5.5", ReasoningLevel::XHigh).unwrap();
3596 assert_eq!(effort, "xhigh");
3597 }
3598
3599 #[test]
3600 fn gpt55_rejects_max_before_network() {
3601 assert!(codex_effort_for("gpt-5.5", ReasoningLevel::Max).is_err());
3602 assert!(codex_effort_for("gpt-5.5", ReasoningLevel::Ultra).is_err());
3603 }
3604
3605 #[test]
3606 fn off_and_adaptive_omit_reasoning_field() {
3607 for level in [ReasoningLevel::Off, ReasoningLevel::Adaptive] {
3609 let body = body_for_level(
3610 "gpt-5.6-sol",
3611 level,
3612 serde_json::json!([]),
3613 "sys".to_string(),
3614 vec![],
3615 None,
3616 None,
3617 );
3618 assert!(
3619 body.get("reasoning").is_none(),
3620 "{level:?} must not emit reasoning field, body={body}"
3621 );
3622 }
3623 }
3624
3625 #[test]
3626 fn max_as_str_is_not_xhigh() {
3627 assert_ne!(ReasoningLevel::Max.as_str(), ReasoningLevel::XHigh.as_str());
3628 assert_eq!(ReasoningLevel::Max.as_str(), "max");
3629 }
3630
3631 #[test]
3632 fn ultra_mode_context_is_exact_proactive_developer_item() {
3633 let plan = plan(ReasoningLevel::Ultra, CodexRequestRole::Foreground);
3634 assert_eq!(plan.multi_agent_mode, Some(CodexMultiAgentMode::Proactive));
3635 let item = super::codex_multi_agent_mode_item(&plan).expect("Ultra context item");
3636 assert_eq!(
3637 item.get("type").and_then(serde_json::Value::as_str),
3638 Some("message")
3639 );
3640 assert_eq!(
3641 item.get("role").and_then(serde_json::Value::as_str),
3642 Some("developer")
3643 );
3644 assert_eq!(
3645 item.pointer("/content/0/text")
3646 .and_then(serde_json::Value::as_str),
3647 Some(concat!(
3648 "<multi_agent_mode>",
3649 "Proactive multi-agent delegation is active. Any earlier instruction requiring an explicit user request before spawning sub-agents no longer applies. Use sub-agents when parallel work would materially improve speed or quality. This mode remains active until a later multi-agent mode developer message changes it.",
3650 "</multi_agent_mode>"
3651 ))
3652 );
3653 }
3654
3655 #[test]
3656 fn max_and_xhigh_mode_context_is_exact_explicit_only_item() {
3657 for level in [ReasoningLevel::Max, ReasoningLevel::XHigh] {
3658 let plan = plan(level, CodexRequestRole::Foreground);
3659 assert_eq!(
3660 plan.multi_agent_mode,
3661 Some(CodexMultiAgentMode::ExplicitRequestOnly)
3662 );
3663 let item = super::codex_multi_agent_mode_item(&plan).expect("v2 context item");
3664 assert_eq!(
3665 item.pointer("/content/0/text")
3666 .and_then(serde_json::Value::as_str),
3667 Some(concat!(
3668 "<multi_agent_mode>",
3669 "Do not spawn sub-agents unless the user or applicable AGENTS.md/skill instructions explicitly ask for sub-agents, delegation, or parallel agent work.",
3670 "</multi_agent_mode>"
3671 )),
3672 "{level:?}"
3673 );
3674 }
3675 }
3676
3677 #[test]
3678 fn mode_context_is_inserted_once_at_stable_head_position() {
3679 let plan = plan(ReasoningLevel::Ultra, CodexRequestRole::Foreground);
3680 let mut input = vec![
3681 serde_json::json!({"role":"user","content":"first"}),
3682 serde_json::json!({"role":"assistant","content":"answer"}),
3683 serde_json::json!({"role":"user","content":"current"}),
3684 ];
3685 super::insert_codex_multi_agent_mode(&mut input, &plan);
3686 let mode_positions = input
3687 .iter()
3688 .enumerate()
3689 .filter(|(_, item)| {
3690 item.pointer("/content/0/text")
3691 .and_then(serde_json::Value::as_str)
3692 .is_some_and(|text| text.starts_with("<multi_agent_mode>"))
3693 })
3694 .map(|(index, _)| index)
3695 .collect::<Vec<_>>();
3696 assert_eq!(mode_positions, vec![0]);
3697 assert_eq!(
3698 input[3].get("content").and_then(serde_json::Value::as_str),
3699 Some("current")
3700 );
3701 }
3702
3703 #[test]
3709 fn mode_context_placement_preserves_prompt_prefix_across_turns() {
3710 let plan = plan(ReasoningLevel::Ultra, CodexRequestRole::Foreground);
3711 let turn1 = vec![
3712 serde_json::json!({"role":"user","content":"first"}),
3713 serde_json::json!({"role":"assistant","content":"answer"}),
3714 ];
3715 let mut turn2 = turn1.clone();
3716 turn2.push(serde_json::json!({"role":"user","content":"follow-up"}));
3717 let mut input1 = turn1;
3718 let mut input2 = turn2;
3719 super::insert_codex_multi_agent_mode(&mut input1, &plan);
3720 super::insert_codex_multi_agent_mode(&mut input2, &plan);
3721 assert_eq!(
3722 &input2[..input1.len()],
3723 &input1[..],
3724 "turn N's input must be a strict prefix of turn N+1's input"
3725 );
3726 }
3727
3728 #[test]
3729 fn prompt_cache_key_is_stable_for_the_same_conversation_head() {
3730 let first = serde_json::json!({"role":"user","content":"hello"});
3731 let a = super::codex_prompt_cache_key("sys", Some(&first));
3732 let b = super::codex_prompt_cache_key("sys", Some(&first));
3733 assert_eq!(a, b);
3734 assert!(a.starts_with("synaps-"), "{a}");
3735 }
3736
3737 #[test]
3738 fn prompt_cache_key_differs_across_conversations() {
3739 let first = serde_json::json!({"role":"user","content":"hello"});
3740 let other = serde_json::json!({"role":"user","content":"different task"});
3741 assert_ne!(
3742 super::codex_prompt_cache_key("sys", Some(&first)),
3743 super::codex_prompt_cache_key("sys", Some(&other)),
3744 );
3745 assert_ne!(
3746 super::codex_prompt_cache_key("sys", Some(&first)),
3747 super::codex_prompt_cache_key("other-sys", Some(&first)),
3748 );
3749 }
3750
3751 #[test]
3752 fn body_carries_prompt_cache_key() {
3753 let body = body_for_level(
3754 "gpt-5.6-sol",
3755 ReasoningLevel::Medium,
3756 serde_json::json!([]),
3757 "sys".to_string(),
3758 vec![],
3759 None,
3760 None,
3761 );
3762 assert_eq!(
3763 body.get("prompt_cache_key")
3764 .and_then(serde_json::Value::as_str),
3765 Some("synaps-test-cache-key")
3766 );
3767 }
3768
3769 #[test]
3770 fn worker_ultra_has_no_mode_context_item() {
3771 let plan = plan(ReasoningLevel::Ultra, CodexRequestRole::Worker);
3772 assert!(super::codex_multi_agent_mode_item(&plan).is_none());
3773 }
3774
3775 #[test]
3778 fn body_lowers_ultra_reasoning_effort_to_max() {
3779 let body = body_for_level(
3780 "gpt-5.6-sol",
3781 ReasoningLevel::Ultra,
3782 serde_json::json!([]),
3783 "sys".to_string(),
3784 vec![],
3785 None,
3786 None,
3787 );
3788 let effort = body
3789 .get("reasoning")
3790 .and_then(|r| r.get("effort"))
3791 .and_then(serde_json::Value::as_str)
3792 .expect("reasoning.effort must be present for Ultra");
3793 assert_eq!(effort, "max");
3794 }
3795
3796 #[test]
3797 fn body_contains_reasoning_effort_for_max() {
3798 let body = body_for_level(
3799 "gpt-5.6-sol",
3800 ReasoningLevel::Max,
3801 serde_json::json!([]),
3802 "sys".to_string(),
3803 vec![],
3804 None,
3805 None,
3806 );
3807 let effort = body
3808 .get("reasoning")
3809 .and_then(|r| r.get("effort"))
3810 .and_then(serde_json::Value::as_str)
3811 .expect("reasoning.effort must be present for Max");
3812 assert_eq!(effort, "max");
3813 assert_ne!(effort, "xhigh");
3815 }
3816
3817 #[test]
3818 fn body_contains_reasoning_effort_for_xhigh() {
3819 let body = body_for_level(
3820 "gpt-5.6-sol",
3821 ReasoningLevel::XHigh,
3822 serde_json::json!([]),
3823 "sys".to_string(),
3824 vec![],
3825 None,
3826 None,
3827 );
3828 let effort = body
3829 .get("reasoning")
3830 .and_then(|r| r.get("effort"))
3831 .and_then(serde_json::Value::as_str)
3832 .expect("reasoning.effort must be present for XHigh");
3833 assert_eq!(effort, "xhigh");
3834 }
3835
3836 #[test]
3837 fn body_contains_reasoning_effort_for_low_medium_high() {
3838 for (level, expected) in [
3839 (ReasoningLevel::Low, "low"),
3840 (ReasoningLevel::Medium, "medium"),
3841 (ReasoningLevel::High, "high"),
3842 ] {
3843 let body = body_for_level(
3844 "gpt-5.6-sol",
3845 level,
3846 serde_json::json!([]),
3847 "sys".to_string(),
3848 vec![],
3849 None,
3850 None,
3851 );
3852 let effort = body
3853 .get("reasoning")
3854 .and_then(|r| r.get("effort"))
3855 .and_then(serde_json::Value::as_str)
3856 .unwrap_or_else(|| panic!("{level:?} must emit reasoning.effort"));
3857 assert_eq!(effort, expected, "{level:?}");
3858 }
3859 }
3860
3861 #[test]
3862 fn body_always_sets_model_and_stream_and_include() {
3863 let body = body_for_level(
3864 "gpt-5.5",
3865 ReasoningLevel::XHigh,
3866 serde_json::json!([]),
3867 "instructions".to_string(),
3868 vec![],
3869 None,
3870 None,
3871 );
3872 assert_eq!(
3873 body.get("model").and_then(serde_json::Value::as_str),
3874 Some("gpt-5.5")
3875 );
3876 assert_eq!(
3877 body.get("stream").and_then(serde_json::Value::as_bool),
3878 Some(true)
3879 );
3880 let includes = body.get("include").expect("include must be present");
3881 assert!(
3882 includes.as_array().is_some_and(|a| {
3883 a.iter()
3884 .any(|v| v.as_str() == Some("reasoning.encrypted_content"))
3885 }),
3886 "include must contain reasoning.encrypted_content"
3887 );
3888 assert_eq!(
3889 body.get("store").and_then(serde_json::Value::as_bool),
3890 Some(false)
3891 );
3892 }
3893
3894 #[test]
3895 fn body_omits_temperature_and_max_tokens_when_none() {
3896 let body = body_for_level(
3897 "gpt-5.6-sol",
3898 ReasoningLevel::Medium,
3899 serde_json::json!([]),
3900 "sys".to_string(),
3901 vec![],
3902 None,
3903 None,
3904 );
3905 assert!(body.get("temperature").is_none());
3906 assert!(body.get("max_output_tokens").is_none());
3907 }
3908
3909 #[test]
3910 fn body_includes_temperature_and_max_tokens_when_some() {
3911 let body = body_for_level(
3912 "gpt-5.6-sol",
3913 ReasoningLevel::High,
3914 serde_json::json!([]),
3915 "sys".to_string(),
3916 vec![],
3917 Some(0.7),
3918 Some(2048),
3919 );
3920 let temp = body
3921 .get("temperature")
3922 .and_then(serde_json::Value::as_f64)
3923 .unwrap();
3924 assert!((temp - 0.7f64).abs() < 0.01, "temperature={temp}");
3925 assert_eq!(
3926 body.get("max_output_tokens")
3927 .and_then(serde_json::Value::as_u64),
3928 Some(2048)
3929 );
3930 }
3931}
3932
3933#[cfg(test)]
3934mod send_retry_tests {
3935 use super::*;
3945 use agent_core::auth::{
3946 AccessToken, BrokerError, ProxyByteStream, ProxyRequest, ProxyResponse,
3947 };
3948 use agent_core::reasoning::ReasoningLevel;
3949 use async_trait::async_trait;
3950 use axum::{http::StatusCode, response::IntoResponse, routing::post as axum_post, Router};
3951 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
3952 use std::sync::{
3953 atomic::{AtomicUsize, Ordering},
3954 Arc,
3955 };
3956 use tokio::sync::mpsc;
3957
3958 const CODEX_SSE_SUCCESS: &str = concat!(
3960 "data: {\"type\":\"response.output_text.delta\",\"delta\":\"hello\"}\n\n",
3961 "data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n",
3962 "data: [DONE]\n\n",
3963 );
3964
3965 fn fake_codex_token() -> String {
3969 let payload = serde_json::json!({
3970 "https://api.openai.com/auth": { "chatgpt_account_id": "acct_test" }
3971 });
3972 format!("h.{}.s", URL_SAFE_NO_PAD.encode(payload.to_string()))
3973 }
3974
3975 struct TokenOnlyBroker;
3978
3979 #[async_trait]
3980 impl crate::auth::CredentialBroker for TokenOnlyBroker {
3981 async fn access_token(
3982 &self,
3983 _p: agent_core::auth::OAuthProviderId,
3984 ) -> Result<AccessToken, BrokerError> {
3985 Ok(AccessToken {
3986 token: fake_codex_token(),
3987 expires: u64::MAX,
3988 })
3989 }
3990 async fn proxy(&self, _request: ProxyRequest) -> Result<ProxyResponse, BrokerError> {
3991 Err(BrokerError::Denied("not implemented in stub".into()))
3992 }
3993 async fn proxy_stream(
3994 &self,
3995 _request: ProxyRequest,
3996 ) -> Result<ProxyByteStream, BrokerError> {
3997 Err(BrokerError::Denied("not implemented in stub".into()))
3998 }
3999 async fn anthropic_usage(&self) -> Result<serde_json::Value, BrokerError> {
4000 Err(BrokerError::Denied("not implemented in stub".into()))
4001 }
4002 async fn capabilities(&self) -> Result<Vec<agent_core::auth::ProviderStatus>, BrokerError> {
4003 Ok(vec![])
4004 }
4005 }
4006
4007 struct DenyingCountingBroker {
4008 access_calls: Arc<AtomicUsize>,
4009 }
4010
4011 #[async_trait]
4012 impl crate::auth::CredentialBroker for DenyingCountingBroker {
4013 async fn access_token(
4014 &self,
4015 _provider: agent_core::auth::OAuthProviderId,
4016 ) -> Result<AccessToken, BrokerError> {
4017 self.access_calls.fetch_add(1, Ordering::SeqCst);
4018 Err(BrokerError::Denied(
4019 "credential access must not occur".into(),
4020 ))
4021 }
4022
4023 async fn proxy(&self, _request: ProxyRequest) -> Result<ProxyResponse, BrokerError> {
4024 Err(BrokerError::Denied("not implemented in stub".into()))
4025 }
4026
4027 async fn proxy_stream(
4028 &self,
4029 _request: ProxyRequest,
4030 ) -> Result<ProxyByteStream, BrokerError> {
4031 Err(BrokerError::Denied("not implemented in stub".into()))
4032 }
4033
4034 async fn anthropic_usage(&self) -> Result<serde_json::Value, BrokerError> {
4035 Err(BrokerError::Denied("not implemented in stub".into()))
4036 }
4037
4038 async fn capabilities(&self) -> Result<Vec<agent_core::auth::ProviderStatus>, BrokerError> {
4039 Ok(vec![])
4040 }
4041 }
4042
4043 async fn spawn_mock_codex(
4046 fail_count: usize,
4047 fail_status: StatusCode,
4048 ) -> (String, Arc<AtomicUsize>) {
4049 let counter = Arc::new(AtomicUsize::new(0));
4050 let counter_clone = Arc::clone(&counter);
4051 let app = Router::new().route(
4052 "/codex/responses",
4053 axum_post(move || {
4054 let counter = Arc::clone(&counter_clone);
4055 async move {
4056 let n = counter.fetch_add(1, Ordering::SeqCst);
4057 if n < fail_count {
4058 (
4059 fail_status,
4060 [("content-type", "application/json")],
4061 "{\"error\":{\"message\":\"transient upstream sadness\"}}".to_string(),
4062 )
4063 .into_response()
4064 } else {
4065 (
4066 StatusCode::OK,
4067 [("content-type", "text/event-stream")],
4068 CODEX_SSE_SUCCESS.to_string(),
4069 )
4070 .into_response()
4071 }
4072 }
4073 }),
4074 );
4075 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4076 let addr = listener.local_addr().unwrap();
4077 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
4078 (format!("http://{addr}"), counter)
4079 }
4080
4081 async fn spawn_codex_sse(body: &'static str) -> String {
4082 let app = Router::new().route(
4083 "/codex/responses",
4084 axum_post(move || async move {
4085 (
4086 StatusCode::OK,
4087 [("content-type", "text/event-stream")],
4088 body,
4089 )
4090 }),
4091 );
4092 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4093 let addr = listener.local_addr().unwrap();
4094 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
4095 format!("http://{addr}")
4096 }
4097
4098 async fn spawn_empty_then_success_codex() -> (
4099 String,
4100 Arc<AtomicUsize>,
4101 Arc<std::sync::Mutex<Vec<bytes::Bytes>>>,
4102 ) {
4103 let counter = Arc::new(AtomicUsize::new(0));
4104 let bodies = Arc::new(std::sync::Mutex::new(Vec::new()));
4105 let app = Router::new().route(
4106 "/codex/responses",
4107 axum_post({
4108 let counter = Arc::clone(&counter);
4109 let bodies = Arc::clone(&bodies);
4110 move |body: bytes::Bytes| {
4111 let counter = Arc::clone(&counter);
4112 let bodies = Arc::clone(&bodies);
4113 async move {
4114 bodies.lock().unwrap().push(body);
4115 let n = counter.fetch_add(1, Ordering::SeqCst);
4116 let response = if n == 0 {
4117 concat!(
4118 "data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":7,\"output_tokens\":0}}}\n\n",
4119 "data: [DONE]\n\n",
4120 )
4121 } else {
4122 CODEX_SSE_SUCCESS
4123 };
4124 (
4125 StatusCode::OK,
4126 [("content-type", "text/event-stream")],
4127 response,
4128 )
4129 }
4130 }
4131 }),
4132 );
4133 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4134 let addr = listener.local_addr().unwrap();
4135 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
4136 (format!("http://{addr}"), counter, bodies)
4137 }
4138
4139 async fn run_codex(
4140 base_url: &str,
4141 max_retries: u32,
4142 ) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
4143 let cfg = ProviderConfig {
4144 base_url: base_url.to_string(),
4145 model: "gpt-5.6-sol".to_string(),
4147 provider: "openai-codex".to_string(),
4148 };
4149 let client = reqwest::Client::new();
4150 let broker: std::sync::Arc<dyn crate::auth::CredentialBroker> =
4151 std::sync::Arc::new(TokenOnlyBroker);
4152 let (tx, _rx) = mpsc::unbounded_channel();
4153 call_codex_stream_inner(
4154 &cfg,
4155 &client,
4156 &broker,
4157 &[],
4158 &Some("test".to_string()),
4159 &[],
4160 &tx,
4161 None,
4162 None,
4163 agent_core::reasoning::ReasoningLevel::Medium,
4164 crate::runtime::openai::catalog::CodexRequestRole::Foreground,
4165 &tokio_util::sync::CancellationToken::new(),
4166 max_retries,
4167 &crate::runtime::trace::TraceContext::disabled(),
4168 )
4169 .await
4170 }
4171
4172 async fn broken_chunk_server(
4175 scripts: Vec<(&'static str, bool)>,
4176 ) -> (
4177 String,
4178 Arc<std::sync::Mutex<Vec<Vec<u8>>>>,
4179 tokio::task::JoinHandle<()>,
4180 ) {
4181 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4182 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4183 let address = listener.local_addr().unwrap();
4184 let requests = Arc::new(std::sync::Mutex::new(Vec::new()));
4185 let seen = requests.clone();
4186 let task = tokio::spawn(async move {
4187 let mut attempt = 0;
4188 loop {
4189 let (mut socket, _) = listener.accept().await.unwrap();
4190 let mut request = Vec::new();
4191 let end = loop {
4192 let mut chunk = [0; 8192];
4193 let n = socket.read(&mut chunk).await.unwrap();
4194 if n == 0 {
4195 return;
4196 }
4197 request.extend_from_slice(&chunk[..n]);
4198 if let Some(end) = request.windows(4).position(|w| w == b"\r\n\r\n") {
4199 break end + 4;
4200 }
4201 };
4202 let length: usize = String::from_utf8_lossy(&request[..end])
4203 .lines()
4204 .find_map(|line| {
4205 line.to_ascii_lowercase()
4206 .strip_prefix("content-length: ")
4207 .map(str::to_owned)
4208 })
4209 .unwrap()
4210 .parse()
4211 .unwrap();
4212 while request.len() < end + length {
4213 let mut chunk = [0; 8192];
4214 let n = socket.read(&mut chunk).await.unwrap();
4215 if n == 0 {
4216 return;
4217 }
4218 request.extend_from_slice(&chunk[..n]);
4219 }
4220 seen.lock()
4221 .unwrap()
4222 .push(request[end..end + length].to_vec());
4223 let (body, broken) = scripts[attempt.min(scripts.len() - 1)];
4224 attempt += 1;
4225 socket.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n").await.unwrap();
4226 socket
4227 .write_all(format!("{:x}\r\n{}\r\n", body.len(), body).as_bytes())
4228 .await
4229 .unwrap();
4230 if !broken {
4232 socket.write_all(b"0\r\n\r\n").await.unwrap();
4233 }
4234 socket.shutdown().await.unwrap();
4235 }
4236 });
4237 (format!("http://{address}"), requests, task)
4238 }
4239
4240 async fn codex_with_events(
4241 base: &str,
4242 retries: u32,
4243 cancel: &tokio_util::sync::CancellationToken,
4244 tx: &mpsc::UnboundedSender<StreamEvent>,
4245 ) -> Result<Value, super::super::net::BoxedProviderError> {
4246 let cfg = ProviderConfig {
4247 base_url: base.into(),
4248 model: "gpt-5.6-sol".into(),
4249 provider: "openai-codex".into(),
4250 };
4251 let broker: Arc<dyn crate::auth::CredentialBroker> = Arc::new(TokenOnlyBroker);
4252 call_codex_stream_inner(
4253 &cfg,
4254 &reqwest::Client::new(),
4255 &broker,
4256 &[],
4257 &Some("test".into()),
4258 &[],
4259 tx,
4260 None,
4261 None,
4262 agent_core::reasoning::ReasoningLevel::Medium,
4263 crate::runtime::openai::catalog::CodexRequestRole::Foreground,
4264 cancel,
4265 retries,
4266 &crate::runtime::trace::TraceContext::disabled(),
4267 )
4268 .await
4269 }
4270
4271 const PARTIAL_TOOL: &str = concat!(
4272 "data: {\"type\":\"response.output_text.delta\",\"delta\":\"discard me\"}\n\n",
4273 "data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"function_call\",\"call_id\":\"old_call\",\"name\":\"write\",\"arguments\":\"\"}}\n\n",
4274 "data: {\"type\":\"response.function_call_arguments.delta\",\"output_index\":0,\"delta\":\"{\\\"path\\\":\"}\n\n",
4275 );
4276 const COMPLETE_TOOL: &str = concat!(
4277 "data: {\"type\":\"response.output_text.delta\",\"delta\":\"accepted\"}\n\n",
4278 "data: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"type\":\"function_call\",\"call_id\":\"new_call\",\"name\":\"write\",\"arguments\":\"{\\\"path\\\":\\\"ok\\\"}\"}}\n\n",
4279 "data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":3,\"output_tokens\":2}}}\n\n",
4280 );
4281
4282 #[tokio::test]
4283 async fn interrupted_partial_tool_retries_identical_bytes_and_commits_only_success() {
4284 use crate::LlmEvent;
4285 let (url, requests, server) =
4286 broken_chunk_server(vec![(PARTIAL_TOOL, true), (COMPLETE_TOOL, false)]).await;
4287 let (tx, mut rx) = mpsc::unbounded_channel();
4288 let result = codex_with_events(&url, 1, &tokio_util::sync::CancellationToken::new(), &tx)
4289 .await
4290 .unwrap();
4291 server.abort();
4292 let requests = requests.lock().unwrap();
4293 assert_eq!(requests.len(), 2);
4294 assert_eq!(requests[0], requests[1]);
4295 let tools: Vec<_> = result["content"]
4296 .as_array()
4297 .unwrap()
4298 .iter()
4299 .filter(|v| v["type"] == "tool_use")
4300 .collect();
4301 assert_eq!(tools.len(), 1);
4302 assert_eq!(tools[0]["id"], "new_call");
4303 assert_eq!(result["content"][0]["text"], "accepted");
4304 let mut preview = String::from("prior completed round");
4305 let mut baseline = 0;
4306 let mut resets = 0;
4307 let mut input_tokens = 0;
4308 while let Ok(event) = rx.try_recv() {
4309 match event {
4310 StreamEvent::Llm(LlmEvent::ResponseStart) => baseline = preview.len(),
4311 StreamEvent::Llm(LlmEvent::ResponseReset) => {
4312 preview.truncate(baseline);
4313 resets += 1;
4314 }
4315 StreamEvent::Llm(LlmEvent::Text(t)) => preview.push_str(&t),
4316 StreamEvent::Session(crate::SessionEvent::Usage {
4317 input_tokens: n, ..
4318 }) => input_tokens += n,
4319 _ => {}
4320 }
4321 }
4322 assert_eq!(resets, 1);
4323 assert_eq!(preview, "prior completed roundaccepted");
4324 assert_eq!(input_tokens, 3);
4325 }
4326
4327 #[tokio::test]
4328 async fn completed_response_followed_by_chunk_error_is_not_replayed() {
4329 let (url, requests, server) = broken_chunk_server(vec![(CODEX_SSE_SUCCESS, true)]).await;
4330 assert!(run_codex(&url, 2).await.is_ok());
4331 server.abort();
4332 assert_eq!(requests.lock().unwrap().len(), 1);
4333 }
4334
4335 #[tokio::test]
4336 async fn clean_truncated_eof_retries_but_never_accepts_partial_tools() {
4337 let (url, requests, server) = broken_chunk_server(vec![(PARTIAL_TOOL, false)]).await;
4338 assert!(run_codex(&url, 1).await.is_err());
4339 server.abort();
4340 assert_eq!(requests.lock().unwrap().len(), 2);
4341 }
4342
4343 #[tokio::test]
4344 async fn broken_chunk_retry_budget_is_finite() {
4345 let (url, requests, server) = broken_chunk_server(vec![(PARTIAL_TOOL, true)]).await;
4346 assert!(run_codex(&url, 1).await.is_err());
4347 server.abort();
4348 assert_eq!(requests.lock().unwrap().len(), 2);
4349 }
4350
4351 #[tokio::test]
4352 async fn cancellation_during_stream_retry_prevents_second_dispatch() {
4353 let (url, requests, server) = broken_chunk_server(vec![(PARTIAL_TOOL, true)]).await;
4354 let (tx, mut rx) = mpsc::unbounded_channel();
4355 let cancel = tokio_util::sync::CancellationToken::new();
4356 let waiter = cancel.clone();
4357 let task = tokio::spawn(async move {
4358 while let Some(event) = rx.recv().await {
4359 if matches!(event, StreamEvent::Session(crate::SessionEvent::Notice(_))) {
4360 waiter.cancel();
4361 break;
4362 }
4363 }
4364 });
4365 let error = codex_with_events(&url, 10, &cancel, &tx).await.unwrap_err();
4366 task.await.unwrap();
4367 server.abort();
4368 assert_eq!(error.to_string(), "request canceled");
4369 assert_eq!(requests.lock().unwrap().len(), 1);
4370 }
4371
4372 #[tokio::test]
4373 async fn direct_ultra_tool_guard_precedes_broker_access() {
4374 let access_calls = Arc::new(AtomicUsize::new(0));
4375 let broker: Arc<dyn crate::auth::CredentialBroker> = Arc::new(DenyingCountingBroker {
4376 access_calls: Arc::clone(&access_calls),
4377 });
4378 let cfg = ProviderConfig {
4379 base_url: "http://127.0.0.1:1".to_string(),
4380 model: "gpt-5.6-sol".to_string(),
4381 provider: "openai-codex".to_string(),
4382 };
4383 let (tx, _rx) = mpsc::unbounded_channel();
4384
4385 let error = call_codex_stream_inner(
4386 &cfg,
4387 &reqwest::Client::new(),
4388 &broker,
4389 &[],
4390 &Some("test".to_string()),
4391 &[],
4392 &tx,
4393 None,
4394 None,
4395 ReasoningLevel::Ultra,
4396 crate::runtime::openai::catalog::CodexRequestRole::Foreground,
4397 &tokio_util::sync::CancellationToken::new(),
4398 0,
4399 &crate::runtime::trace::TraceContext::disabled(),
4400 )
4401 .await
4402 .expect_err("foreground Ultra without delegation tools must fail closed");
4403
4404 assert!(error.to_string().contains("subagent_start"), "{error}");
4405 assert_eq!(
4406 access_calls.load(Ordering::SeqCst),
4407 0,
4408 "preflight denial must happen before broker credential access"
4409 );
4410 }
4411
4412 #[tokio::test]
4413 async fn direct_codex_path_rejects_provider_identity_before_broker_access() {
4414 let access_calls = Arc::new(AtomicUsize::new(0));
4415 let broker: Arc<dyn crate::auth::CredentialBroker> = Arc::new(DenyingCountingBroker {
4416 access_calls: Arc::clone(&access_calls),
4417 });
4418 let cfg = ProviderConfig {
4419 base_url: "http://127.0.0.1:1".to_string(),
4420 model: "gpt-5.6-sol".to_string(),
4421 provider: "openrouter".to_string(),
4422 };
4423 let (tx, _rx) = mpsc::unbounded_channel();
4424
4425 let error = call_codex_stream_inner(
4426 &cfg,
4427 &reqwest::Client::new(),
4428 &broker,
4429 &[],
4430 &Some("test".to_string()),
4431 &[],
4432 &tx,
4433 None,
4434 None,
4435 ReasoningLevel::Medium,
4436 crate::runtime::openai::catalog::CodexRequestRole::Foreground,
4437 &tokio_util::sync::CancellationToken::new(),
4438 0,
4439 &crate::runtime::trace::TraceContext::disabled(),
4440 )
4441 .await
4442 .expect_err("the Codex seam must validate provider-qualified identity");
4443
4444 assert!(error.to_string().contains("openrouter"), "{error}");
4445 assert_eq!(
4446 access_calls.load(Ordering::SeqCst),
4447 0,
4448 "provider identity denial must precede broker credential access"
4449 );
4450 }
4451
4452 #[tokio::test]
4458 async fn codex_response_failed_event_is_a_terminal_error_not_empty_success() {
4459 let base_url = spawn_codex_sse(concat!(
4460 "data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"type\":\"server_error\",\"message\":\"ECHOED:secret prompt\"}}}\n\n",
4461 "data: [DONE]\n\n",
4462 ))
4463 .await;
4464 let err = run_codex(&base_url, 0)
4465 .await
4466 .expect_err("response.failed must fail the request");
4467 let msg = err.to_string();
4468 assert!(
4469 msg == format!("Codex{RESPONSES_CAPACITY_SUFFIX}"),
4470 "static transient failure message expected: {msg}"
4471 );
4472 assert!(
4473 !msg.contains("ECHOED") && !msg.contains("secret prompt"),
4474 "provider error body must never surface: {msg}"
4475 );
4476 }
4477
4478 #[tokio::test]
4481 async fn codex_response_incomplete_event_is_a_terminal_error() {
4482 let base_url = spawn_codex_sse(concat!(
4483 "data: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\n\n",
4484 "data: {\"type\":\"response.incomplete\",\"response\":{\"incomplete_details\":{\"reason\":\"max_output_tokens\"}}}\n\n",
4485 "data: [DONE]\n\n",
4486 ))
4487 .await;
4488 let err = run_codex(&base_url, 0)
4489 .await
4490 .expect_err("response.incomplete must fail the request");
4491 assert!(
4492 err.to_string()
4493 .starts_with("Codex response was incomplete."),
4494 "got: {err}"
4495 );
4496 }
4497
4498 #[tokio::test]
4501 async fn codex_eof_without_terminal_event_fails_closed() {
4502 let base_url = spawn_codex_sse(
4503 "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\"}}\n\n",
4504 )
4505 .await;
4506 let err = run_codex(&base_url, 0)
4507 .await
4508 .expect_err("EOF without terminal event must fail");
4509 assert!(
4510 err.to_string()
4511 .starts_with("Codex response stream ended without a terminal event."),
4512 "got: {err}"
4513 );
4514 }
4515
4516 #[tokio::test]
4517 async fn codex_completed_without_output_retries_identical_bytes_then_succeeds() {
4518 let (base_url, counter, bodies) = spawn_empty_then_success_codex().await;
4519 let result = run_codex(&base_url, 1)
4520 .await
4521 .expect("one empty-success stream with retries available must not abort the turn");
4522
4523 assert_eq!(counter.load(Ordering::SeqCst), 2, "expected one retry");
4524 let received = bodies.lock().unwrap();
4525 assert_eq!(received.len(), 2, "both attempts must reach the endpoint");
4526 assert_eq!(
4527 received[0], received[1],
4528 "the logical request must resend byte-identical content"
4529 );
4530 assert_eq!(result["content"][0]["text"], "hello");
4531 }
4532
4533 async fn spawn_header_recording_codex(
4536 fail_count: usize,
4537 ) -> (String, Arc<std::sync::Mutex<Vec<(String, String, String)>>>) {
4538 let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
4539 let counter = Arc::new(AtomicUsize::new(0));
4540 let app = Router::new().route(
4541 "/codex/responses",
4542 axum_post({
4543 let seen = Arc::clone(&seen);
4544 move |headers: axum::http::HeaderMap, body: bytes::Bytes| {
4545 let seen = Arc::clone(&seen);
4546 let counter = Arc::clone(&counter);
4547 async move {
4548 let get = |name: &str| {
4549 headers
4550 .get(name)
4551 .and_then(|v| v.to_str().ok())
4552 .unwrap_or("")
4553 .to_string()
4554 };
4555 let key = serde_json::from_slice::<Value>(&body)
4556 .ok()
4557 .and_then(|b| b["prompt_cache_key"].as_str().map(str::to_string))
4558 .unwrap_or_default();
4559 seen.lock()
4560 .unwrap()
4561 .push((get("session-id"), get("thread-id"), key));
4562 if counter.fetch_add(1, Ordering::SeqCst) < fail_count {
4563 (StatusCode::INTERNAL_SERVER_ERROR, "{}".to_string()).into_response()
4564 } else {
4565 (
4566 StatusCode::OK,
4567 [("content-type", "text/event-stream")],
4568 CODEX_SSE_SUCCESS.to_string(),
4569 )
4570 .into_response()
4571 }
4572 }
4573 }
4574 }),
4575 );
4576 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4577 let addr = listener.local_addr().unwrap();
4578 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
4579 (format!("http://{addr}"), seen)
4580 }
4581
4582 #[tokio::test]
4587 async fn codex_requests_carry_session_and_thread_id_equal_to_the_cache_key() {
4588 let (base_url, seen) = spawn_header_recording_codex(1).await;
4589 run_codex(&base_url, 2)
4590 .await
4591 .expect("one transient 500 then success");
4592 let seen = seen.lock().unwrap();
4593 assert_eq!(seen.len(), 2, "first attempt + one retry");
4594 for (session_id, thread_id, key) in seen.iter() {
4595 assert!(
4596 key.starts_with("synaps-"),
4597 "prompt_cache_key in body: {key:?}"
4598 );
4599 assert_eq!(session_id, key, "session-id header = prompt_cache_key");
4600 assert_eq!(thread_id, key, "thread-id header = prompt_cache_key");
4601 }
4602 assert_eq!(seen[0], seen[1], "a retry keeps the same cache routing");
4603 }
4604
4605 #[tokio::test]
4606 async fn codex_retries_transient_500_then_succeeds() {
4607 let (base_url, counter) = spawn_mock_codex(1, StatusCode::INTERNAL_SERVER_ERROR).await;
4608 let result = run_codex(&base_url, 2)
4609 .await
4610 .expect("one transient 500 with retries available must not abort the turn");
4611 assert_eq!(
4612 counter.load(Ordering::SeqCst),
4613 2,
4614 "expected exactly one retry"
4615 );
4616 let text = result["content"][0]["text"].as_str().unwrap_or_default();
4617 assert_eq!(text, "hello");
4618 }
4619
4620 #[tokio::test]
4621 async fn codex_response_failed_surfaces_typed_provider_error_not_empty_response() {
4622 const BODY: &str = concat!(
4623 "data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"type\":\"server_error\",\"message\":\"ECHOED:private prompt\"}}}\n\n",
4624 "data: [DONE]\n\n",
4625 );
4626 let base_url = spawn_codex_sse(BODY).await;
4627 let err = run_codex(&base_url, 0).await.expect_err("must fail");
4628 let msg = err.to_string();
4629 assert_eq!(msg, format!("Codex{RESPONSES_CAPACITY_SUFFIX}"));
4630 assert!(!msg.contains("empty response"), "{msg}");
4631 assert!(!msg.contains("ECHOED"), "provider text leaked: {msg}");
4632 assert!(
4633 !msg.contains("private prompt"),
4634 "provider text leaked: {msg}"
4635 );
4636 }
4637
4638 #[tokio::test]
4639 async fn codex_response_incomplete_surfaces_typed_provider_error_not_empty_response() {
4640 const BODY: &str = concat!(
4641 "data: {\"type\":\"response.incomplete\",\"response\":{\"incomplete_details\":{\"reason\":\"max_output_tokens\"}}}\n\n",
4642 "data: [DONE]\n\n",
4643 );
4644 let base_url = spawn_codex_sse(BODY).await;
4645 let err = run_codex(&base_url, 0).await.expect_err("must fail");
4646 let msg = err.to_string();
4647 assert!(msg.starts_with("Codex response was incomplete."), "{msg}");
4648 assert!(!msg.contains("empty response"), "{msg}");
4649 assert!(
4650 !msg.contains("max_output_tokens"),
4651 "provider text leaked: {msg}"
4652 );
4653 }
4654
4655 #[tokio::test]
4656 async fn codex_clean_eof_without_terminal_fails_closed() {
4657 let base_url = spawn_codex_sse(
4658 "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_123\"}}\n\n",
4659 )
4660 .await;
4661 let err = run_codex(&base_url, 0).await.expect_err("must fail");
4662 assert!(
4663 err.to_string()
4664 .starts_with("Codex response stream ended without a terminal event."),
4665 "{err}"
4666 );
4667 }
4668
4669 #[tokio::test]
4670 async fn codex_retries_429_then_succeeds() {
4671 let (base_url, counter) = spawn_mock_codex(1, StatusCode::TOO_MANY_REQUESTS).await;
4672 run_codex(&base_url, 2)
4673 .await
4674 .expect("429 is transient — must retry");
4675 assert_eq!(counter.load(Ordering::SeqCst), 2);
4676 }
4677
4678 #[tokio::test]
4679 async fn codex_zero_retries_fails_fast_with_status_error() {
4680 let (base_url, counter) =
4681 spawn_mock_codex(usize::MAX, StatusCode::INTERNAL_SERVER_ERROR).await;
4682 let err = run_codex(&base_url, 0).await.expect_err("must fail");
4683 assert_eq!(counter.load(Ordering::SeqCst), 1, "no retries budgeted");
4684 let msg = err.to_string();
4685 assert!(
4686 msg.starts_with("codex request failed: 500"),
4687 "status must survive for classification: {msg}"
4688 );
4689 }
4690
4691 #[tokio::test]
4692 async fn codex_does_not_retry_client_errors() {
4693 let (base_url, counter) = spawn_mock_codex(usize::MAX, StatusCode::BAD_REQUEST).await;
4694 let err = run_codex(&base_url, 3).await.expect_err("must fail");
4695 assert_eq!(
4696 counter.load(Ordering::SeqCst),
4697 1,
4698 "400 is deterministic — retrying it never helps"
4699 );
4700 assert!(err.to_string().starts_with("codex request failed: 400"));
4701 }
4702
4703 #[tokio::test]
4704 async fn codex_exhausted_retries_reports_last_status() {
4705 let (base_url, counter) =
4706 spawn_mock_codex(usize::MAX, StatusCode::SERVICE_UNAVAILABLE).await;
4707 let err = run_codex(&base_url, 1).await.expect_err("must fail");
4708 assert_eq!(
4709 counter.load(Ordering::SeqCst),
4710 2,
4711 "initial attempt + 1 retry"
4712 );
4713 assert!(err.to_string().starts_with("codex request failed: 503"));
4714 }
4715
4716 #[test]
4720 fn codex_retry_budget_lifts_configured_default_to_persistent_floor() {
4721 assert_eq!(codex_retry_budget(3), CODEX_PERSISTENT_RETRIES);
4722 assert_eq!(codex_retry_budget(0), CODEX_PERSISTENT_RETRIES);
4723 assert_eq!(codex_retry_budget(10), 10);
4724 }
4725
4726 #[test]
4729 fn codex_retry_budget_honors_larger_user_configuration() {
4730 assert_eq!(codex_retry_budget(15), 15);
4731 }
4732
4733 #[test]
4736 fn retry_delay_is_exponential_with_a_capped_tail() {
4737 assert_eq!(retry_delay(1).as_secs(), 1);
4738 assert_eq!(retry_delay(2).as_secs(), 2);
4739 assert_eq!(retry_delay(4).as_secs(), 8);
4740 assert_eq!(retry_delay(7).as_secs(), 64);
4741 assert_eq!(retry_delay(10).as_secs(), 64);
4742 }
4743
4744 const ECHO_SENTINEL: &str = "PH1-OAI-SENTINEL-2b8c4d-RAW-CONTENT";
4752
4753 struct CaptureWriter(Arc<std::sync::Mutex<Vec<u8>>>);
4755
4756 impl std::io::Write for CaptureWriter {
4757 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
4758 self.0.lock().unwrap().extend_from_slice(buf);
4759 Ok(buf.len())
4760 }
4761 fn flush(&mut self) -> std::io::Result<()> {
4762 Ok(())
4763 }
4764 }
4765
4766 fn capture_subscriber(
4767 buf: &Arc<std::sync::Mutex<Vec<u8>>>,
4768 ) -> impl tracing::Subscriber + Send + Sync {
4769 let sink = buf.clone();
4770 tracing_subscriber::fmt()
4771 .with_max_level(tracing::Level::TRACE)
4772 .with_writer(move || CaptureWriter(sink.clone()))
4773 .with_ansi(false)
4774 .finish()
4775 }
4776
4777 async fn spawn_hostile_echo_provider(status: StatusCode) -> String {
4781 let app = Router::new().fallback(move |body: String| async move {
4782 let envelope = serde_json::json!({
4783 "error": { "message": format!("ECHOED:{body}") }
4784 });
4785 (
4786 status,
4787 [("content-type", "application/json")],
4788 envelope.to_string(),
4789 )
4790 .into_response()
4791 });
4792 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4793 let addr = listener.local_addr().unwrap();
4794 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
4795 format!("http://{addr}")
4796 }
4797
4798 fn sentinel_request_body() -> Value {
4802 json!({
4803 "model": "gpt-test",
4804 "messages": [
4805 {"role": "system", "content": format!("system secret {ECHO_SENTINEL}")},
4806 {"role": "user", "content": ECHO_SENTINEL},
4807 ],
4808 "tools": [{
4809 "type": "function",
4810 "function": {
4811 "name": "ph1_secret_tool_zz",
4812 "description": "internal tool schema",
4813 "parameters": {"type": "object", "properties": {}}
4814 }
4815 }],
4816 })
4817 }
4818
4819 const BANNED_MARKERS: &[&str] = &[
4821 "ECHOED",
4822 ECHO_SENTINEL,
4823 "ph1_secret_tool_zz",
4824 "\"messages\"",
4825 "system secret",
4826 ];
4827
4828 fn assert_no_banned(haystack: &str, context: &str) {
4829 for banned in BANNED_MARKERS {
4830 assert!(
4831 !haystack.contains(banned),
4832 "provider body content `{banned}` leaked into {context}: {haystack}"
4833 );
4834 }
4835 }
4836
4837 #[tokio::test]
4838 async fn send_with_retries_non_retryable_error_never_surfaces_provider_body() {
4839 let url = spawn_hostile_echo_provider(StatusCode::BAD_REQUEST).await;
4840 let client = reqwest::Client::new();
4841 let body = sentinel_request_body();
4842
4843 let buf = Arc::new(std::sync::Mutex::new(Vec::new()));
4844 let _guard = tracing::subscriber::set_default(capture_subscriber(&buf));
4845
4846 let err = send_with_retries(
4847 "codex",
4848 &url,
4849 || client.post(&url).json(&body),
4850 &tokio_util::sync::CancellationToken::new(),
4851 3,
4852 &mut 0,
4853 &mut tr::StreamAttempt::new(None),
4854 )
4855 .await
4856 .expect_err("400 must fail fast");
4857
4858 let msg = err.to_string();
4859 assert!(
4860 msg.starts_with("codex request failed: 400"),
4861 "status + label must survive for classification/usability: {msg}"
4862 );
4863 assert_no_banned(&msg, "the surfaced error");
4864 let logs = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
4865 assert_no_banned(&logs, "tracing output");
4866 }
4867
4868 #[tokio::test(start_paused = true)]
4869 async fn send_with_retries_retry_logs_never_contain_provider_body() {
4870 let url = spawn_hostile_echo_provider(StatusCode::SERVICE_UNAVAILABLE).await;
4871 let client = reqwest::Client::new();
4872 let body = sentinel_request_body();
4873
4874 let buf = Arc::new(std::sync::Mutex::new(Vec::new()));
4875 let _guard = tracing::subscriber::set_default(capture_subscriber(&buf));
4876
4877 let err = send_with_retries(
4878 "codex",
4879 &url,
4880 || client.post(&url).json(&body),
4881 &tokio_util::sync::CancellationToken::new(),
4882 1,
4883 &mut 0,
4884 &mut tr::StreamAttempt::new(None),
4885 )
4886 .await
4887 .expect_err("persistent 503 must exhaust the budget");
4888
4889 let msg = err.to_string();
4890 assert!(
4891 msg.starts_with("codex request failed: 503"),
4892 "status + label must survive after exhaustion: {msg}"
4893 );
4894 assert_no_banned(&msg, "the surfaced error");
4895
4896 let logs = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
4897 assert!(
4898 logs.contains("retry 1/1"),
4899 "retry accounting must stay observable: {logs}"
4900 );
4901 assert!(
4902 logs.contains("503"),
4903 "retry log must keep the status for diagnosis: {logs}"
4904 );
4905 assert_no_banned(&logs, "retry tracing output");
4906 }
4907}
4908
4909#[cfg(test)]
4910mod interrupted_broker_tests {
4911 use super::*;
4912 use crate::auth::{
4913 AccessToken, BrokerError, CredentialBroker, ProxyByteStream, ProxyRequest, ProxyResponse,
4914 };
4915 use std::sync::{Arc, Mutex};
4916 use tokio_util::sync::CancellationToken;
4917
4918 struct Broker {
4919 bodies: Vec<(&'static str, Option<BrokerError>)>,
4920 seen: Mutex<Vec<ProxyRequest>>,
4921 }
4922 #[async_trait::async_trait]
4923 impl CredentialBroker for Broker {
4924 async fn access_token(
4925 &self,
4926 _: crate::auth::OAuthProviderId,
4927 ) -> Result<AccessToken, BrokerError> {
4928 unreachable!()
4929 }
4930 async fn proxy(&self, _: ProxyRequest) -> Result<ProxyResponse, BrokerError> {
4931 unreachable!()
4932 }
4933 async fn proxy_stream(
4934 &self,
4935 request: ProxyRequest,
4936 ) -> Result<ProxyByteStream, BrokerError> {
4937 let mut seen = self.seen.lock().unwrap();
4938 let (body, error) = &self.bodies[seen.len().min(self.bodies.len() - 1)];
4939 seen.push(request);
4940 let mut chunks = vec![Ok(bytes::Bytes::from_static(body.as_bytes()))];
4941 if let Some(error) = error {
4942 chunks.push(Err(error.clone()));
4943 }
4944 Ok(Box::pin(futures::stream::iter(chunks)))
4945 }
4946 async fn anthropic_usage(&self) -> Result<Value, BrokerError> {
4947 unreachable!()
4948 }
4949 async fn capabilities(&self) -> Result<Vec<agent_core::auth::ProviderStatus>, BrokerError> {
4950 Ok(vec![])
4951 }
4952 }
4953 const CHAT_PARTIAL: &str = concat!(
4954 "data: {\"choices\":[{\"delta\":{\"content\":\"discard\"}}]}\n\n",
4955 "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":7,\"completion_tokens\":1}}\n\n"
4956 );
4957 const CHAT_OK: &str =
4958 "data: {\"choices\":[{\"delta\":{\"content\":\"accepted\"}}]}\n\ndata: [DONE]\n\n";
4959 const XAI_PARTIAL: &str =
4960 "data: {\"type\":\"response.output_text.delta\",\"delta\":\"discard\"}\n\n";
4961 const XAI_OK: &str = "data: {\"type\":\"response.output_text.delta\",\"delta\":\"accepted\"}\n\ndata: {\"type\":\"response.completed\"}\n\n";
4962
4963 async fn drive(
4964 broker: Arc<Broker>,
4965 xai: bool,
4966 retries: u32,
4967 suppressed: bool,
4968 cancel: &CancellationToken,
4969 ) -> (
4970 Result<Value, super::super::net::BoxedProviderError>,
4971 Vec<StreamEvent>,
4972 ) {
4973 let cfg = ProviderConfig {
4974 base_url: "http://unused.invalid".into(),
4975 model: if xai { "grok-4.5" } else { "test" }.into(),
4976 provider: if xai { "xai-auth" } else { "local" }.into(),
4977 };
4978 let broker: Arc<dyn CredentialBroker> = broker;
4979 let (tx, mut rx) = mpsc::unbounded_channel();
4980 let trace = crate::runtime::trace::TraceContext::disabled();
4981 let result = if xai {
4982 call_xai_responses_stream_inner(
4983 &cfg,
4984 &broker,
4985 &[],
4986 &None,
4987 &[],
4988 &tx,
4989 None,
4990 agent_core::reasoning::ReasoningLevel::Adaptive,
4991 cancel,
4992 retries,
4993 &trace,
4994 true,
4995 )
4996 .await
4997 } else {
4998 call_oai_stream_inner(
4999 &cfg,
5000 &broker,
5001 &[],
5002 &None,
5003 &[],
5004 &tx,
5005 None,
5006 None,
5007 0,
5008 agent_core::reasoning::ReasoningLevel::Medium,
5009 cancel,
5010 &trace,
5011 true,
5012 suppressed,
5013 retries,
5014 )
5015 .await
5016 };
5017 let mut events = vec![];
5018 while let Ok(event) = rx.try_recv() {
5019 events.push(event);
5020 }
5021 (result, events)
5022 }
5023
5024 #[tokio::test(start_paused = true)]
5025 async fn broker_transport_recovery_preserves_usage_and_identical_bytes() {
5026 for xai in [false, true] {
5027 let broker = Arc::new(Broker {
5028 bodies: vec![
5029 (
5030 if xai { XAI_PARTIAL } else { CHAT_PARTIAL },
5031 Some(BrokerError::Transport(
5032 "unexpected EOF during chunk size line".into(),
5033 )),
5034 ),
5035 (if xai { XAI_OK } else { CHAT_OK }, None),
5036 ],
5037 seen: Mutex::new(vec![]),
5038 });
5039 let (result, events) =
5040 drive(broker.clone(), xai, 1, false, &CancellationToken::new()).await;
5041 assert_eq!(result.unwrap()["content"][0]["text"], "accepted");
5042 let seen = broker.seen.lock().unwrap();
5043 assert_eq!(seen.len(), 2);
5044 assert_eq!(seen[0].body_bytes, seen[1].body_bytes);
5045 assert_eq!(
5046 events
5047 .iter()
5048 .filter(|e| matches!(e, StreamEvent::Llm(crate::LlmEvent::ResponseReset)))
5049 .count(),
5050 1
5051 );
5052 if !xai {
5053 assert_eq!(
5054 events
5055 .iter()
5056 .filter_map(|e| match e {
5057 StreamEvent::Session(crate::SessionEvent::Usage {
5058 input_tokens,
5059 ..
5060 }) => Some(*input_tokens),
5061 _ => None,
5062 })
5063 .sum::<u64>(),
5064 7
5065 );
5066 }
5067 }
5068 }
5069 #[tokio::test(start_paused = true)]
5070 async fn broker_nontransport_denial_never_retries_and_precancel_never_dispatches() {
5071 for xai in [false, true] {
5072 let broker = Arc::new(Broker {
5073 bodies: vec![(
5074 if xai { XAI_PARTIAL } else { CHAT_PARTIAL },
5075 Some(BrokerError::Denied("blocked".into())),
5076 )],
5077 seen: Mutex::new(vec![]),
5078 });
5079 let (result, events) =
5080 drive(broker.clone(), xai, 10, false, &CancellationToken::new()).await;
5081 assert!(result.is_err());
5082 assert_eq!(broker.seen.lock().unwrap().len(), 1);
5083 assert!(!events
5084 .iter()
5085 .any(|e| matches!(e, StreamEvent::Llm(crate::LlmEvent::ResponseReset))));
5086 let canceled = CancellationToken::new();
5087 canceled.cancel();
5088 assert!(drive(broker.clone(), xai, 10, false, &canceled)
5089 .await
5090 .0
5091 .is_err());
5092 assert_eq!(broker.seen.lock().unwrap().len(), 1);
5093 }
5094 }
5095 #[tokio::test(start_paused = true)]
5096 async fn suppressed_retry_emits_no_llm_previews() {
5097 let broker = Arc::new(Broker {
5098 bodies: vec![
5099 (CHAT_PARTIAL, Some(BrokerError::Transport("eof".into()))),
5100 (CHAT_OK, None),
5101 ],
5102 seen: Mutex::new(vec![]),
5103 });
5104 let (result, events) = drive(broker, false, 1, true, &CancellationToken::new()).await;
5105 assert!(result.is_ok());
5106 assert!(!events.iter().any(|e| matches!(e, StreamEvent::Llm(_))));
5107 }
5108 #[test]
5109 fn exhaustion_is_transient_for_auto_driver_but_not_arbitrary_error_prose() {
5110 use crate::extensions::session_driver::{classify_error, Outcome};
5111 assert_eq!(
5112 classify_error(STREAM_INTERRUPTED),
5113 (Outcome::ProviderError, "transient".into())
5114 );
5115 assert_eq!(
5116 classify_error(&format!("API error: {STREAM_INTERRUPTED}")),
5117 (Outcome::ProviderError, "transient".into())
5118 );
5119 assert_eq!(
5120 classify_error(&format!("denied: {STREAM_INTERRUPTED}")).0,
5121 Outcome::Blocked
5122 );
5123 }
5124}