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("content-type", "application/json")
681 .header("accept", "text/event-stream")
682 .body(body_bytes.clone())
683 },
684 cancel,
685 max_retries,
686 &mut stream_retry,
687 &mut attempt,
688 )
689 .await?;
690 let http_status = Some(resp.status().as_u16());
692 let trace_rid = tr::provider_request_id_from_headers(resp.headers());
693
694 let _ = tx.send(StreamEvent::Llm(crate::LlmEvent::ResponseStart));
695 let mut interrupted = false;
696 let mut accumulated_text = String::new();
697 let mut parser = CodexSseDecoder::default();
698 let mut buf = bytes::BytesMut::with_capacity(8 * 1024);
699 let mut stream = resp.bytes_stream();
700
701 while let Some(chunk) = tokio::select! {
702 biased;
703 _ = cancel.cancelled() => {
704 attempt.finish_canceled(http_status, parser.trace_usage());
705 return Err("request canceled".into());
706 }
707 chunk = stream.next() => chunk,
708 } {
709 let chunk = match chunk {
710 Ok(chunk) => {
711 attempt.mark_first_byte();
712 chunk
713 }
714 Err(_) => {
715 interrupted = !parser.terminal_success && parser.terminal_failure.is_none();
716 break;
717 }
718 };
719 buf.extend_from_slice(&chunk);
720 while let Some(nl) = memchr::memchr(b'\n', &buf) {
721 let line_bytes = buf.split_to(nl + 1);
722 let line = std::str::from_utf8(&line_bytes[..nl]).unwrap_or("");
723 parser.push_line(line, tx, &mut accumulated_text);
724 }
725 if parser.saw_model_event {
726 attempt.mark_first_model_event();
727 }
728 }
729 if !interrupted && !buf.is_empty() {
730 let line = std::str::from_utf8(&buf).unwrap_or("");
731 parser.push_line(line, tx, &mut accumulated_text);
732 }
733 if interrupted || (!parser.terminal_success && parser.terminal_failure.is_none()) {
734 if stream_retry >= max_retries {
735 attempt.finish_failed("stream_error", http_status, trace_rid);
736 return Err(format!("Codex{RESPONSES_MISSING_TERMINAL_SUFFIX}").into());
737 }
738 stream_retry += 1;
739 attempt.attempt_failed(
740 crate::runtime::trace::RetryClass::Network,
741 retry_delay(stream_retry),
742 http_status,
743 trace_rid,
744 "stream_error",
745 );
746 wait_stream_retry(tx, cancel, stream_retry, max_retries, false)
747 .await
748 .inspect_err(|_| {
749 attempt.finish_canceled(None, None);
750 })?;
751 attempt.restart_clock();
752 continue;
753 }
754 parser.finish();
755 if parser.saw_model_event {
759 attempt.mark_first_model_event();
760 }
761 if let Err(failure) = parser.terminal_result() {
762 if failure.code == "responses_empty" && stream_retry < max_retries {
763 stream_retry += 1;
764 let delay = retry_delay(stream_retry);
765 attempt.attempt_failed(
766 crate::runtime::trace::RetryClass::Other,
767 delay,
768 http_status,
769 trace_rid,
770 failure.code,
771 );
772 tracing::warn!(
773 "codex stream retry {stream_retry}/{max_retries} after {delay:?}: completed without usable output"
774 );
775 wait_stream_retry(tx, cancel, stream_retry, max_retries, false)
776 .await
777 .inspect_err(|_| {
778 attempt.finish_canceled(None, None);
779 })?;
780 attempt.restart_clock();
781 continue;
782 }
783 attempt.finish_failed(failure.code, http_status, trace_rid);
784 return Err(failure.message.into());
785 }
786 attempt.finish_success(http_status, trace_rid, None, parser.trace_usage());
787
788 let mut content: Vec<Value> = Vec::new();
789 if !accumulated_text.is_empty() {
790 content.push(json!({"type": "text", "text": accumulated_text}));
791 }
792 content.extend(translate::tool_calls_to_content_blocks(
793 &parser.completed_tools,
794 &name_map,
795 ));
796
797 return Ok(json!({
798 "role": "assistant",
799 "content": content,
800 }));
801 }
802}
803
804pub(crate) fn codex_prompt_cache_key(instructions: &str, first_item: Option<&Value>) -> String {
816 use sha2::{Digest, Sha256};
817 let mut hasher = Sha256::new();
818 hasher.update(instructions.as_bytes());
819 if let Some(item) = first_item {
820 hasher.update(item.to_string().as_bytes());
821 }
822 format!("synaps-{:x}", hasher.finalize())
823}
824
825#[allow(clippy::too_many_arguments)] pub(crate) fn build_codex_body(
842 model: &str,
843 plan: &crate::runtime::openai::catalog::CodexExecutionPlan,
844 input: serde_json::Value,
845 instructions: String,
846 tools: Vec<serde_json::Value>,
847 temperature: Option<f32>,
848 max_tokens: Option<u32>,
849 prompt_cache_key: &str,
850) -> serde_json::Value {
851 let mut body = json!({
852 "model": model,
853 "store": false,
854 "stream": true,
855 "instructions": instructions,
856 "input": input,
857 "tool_choice": "auto",
858 "parallel_tool_calls": true,
859 "include": ["reasoning.encrypted_content"],
860 "text": { "verbosity": "medium" },
861 "prompt_cache_key": prompt_cache_key,
862 });
863 if let Some(effort) = plan.wire_effort {
864 body["reasoning"] = json!({ "effort": effort.as_str() });
865 }
866 if !tools.is_empty() {
867 body["tools"] = serde_json::Value::Array(tools);
868 }
869 if let Some(temp) = temperature {
870 body["temperature"] = json!(temp);
871 }
872 if let Some(max) = max_tokens {
873 body["max_output_tokens"] = json!(max);
874 }
875 body
876}
877
878const CODEX_MULTI_AGENT_MODE_OPEN: &str = "<multi_agent_mode>";
879const CODEX_MULTI_AGENT_MODE_CLOSE: &str = "</multi_agent_mode>";
880const 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.";
881const 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.";
882
883fn codex_multi_agent_mode_item(
884 plan: &crate::runtime::openai::catalog::CodexExecutionPlan,
885) -> Option<Value> {
886 use crate::runtime::openai::catalog::CodexMultiAgentMode;
887 let body = match plan.multi_agent_mode? {
888 CodexMultiAgentMode::ExplicitRequestOnly => CODEX_EXPLICIT_REQUEST_ONLY_TEXT,
889 CodexMultiAgentMode::Proactive => CODEX_PROACTIVE_MULTI_AGENT_TEXT,
890 };
891 Some(json!({
892 "type": "message",
893 "role": "developer",
894 "content": [{
895 "type": "input_text",
896 "text": format!("{CODEX_MULTI_AGENT_MODE_OPEN}{body}{CODEX_MULTI_AGENT_MODE_CLOSE}"),
897 }],
898 }))
899}
900
901fn is_codex_multi_agent_mode_item(item: &Value) -> bool {
902 item.pointer("/content/0/text")
903 .and_then(Value::as_str)
904 .is_some_and(|text| {
905 text.trim_start().starts_with(CODEX_MULTI_AGENT_MODE_OPEN)
906 && text.trim_end().ends_with(CODEX_MULTI_AGENT_MODE_CLOSE)
907 })
908}
909
910fn insert_codex_multi_agent_mode(
911 input: &mut Vec<Value>,
912 plan: &crate::runtime::openai::catalog::CodexExecutionPlan,
913) {
914 input.retain(|item| !is_codex_multi_agent_mode_item(item));
915 let Some(item) = codex_multi_agent_mode_item(plan) else {
916 return;
917 };
918 input.insert(0, item);
928}
929
930fn codex_has_required_delegation_tools(tools_schema: &[Value]) -> bool {
931 ["subagent_start", "subagent_status", "subagent_collect"]
932 .into_iter()
933 .all(|required| {
934 tools_schema.iter().any(|tool| {
935 tool.get("name").and_then(Value::as_str) == Some(required)
936 || tool.pointer("/function/name").and_then(Value::as_str) == Some(required)
937 })
938 })
939}
940
941const 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]";
942
943fn codex_instructions(system_prompt: &Option<String>) -> String {
944 let mut instructions = system_prompt.clone().unwrap_or_default();
945 if instructions.contains("[Synaps autonomous harness policy]") {
946 return instructions;
947 }
948 instructions.push_str(CODEX_AUTONOMOUS_LOOP_POLICY);
949 instructions
950}
951
952fn codex_input_messages(messages: Vec<ChatMessage>) -> Vec<Value> {
953 let mut out = Vec::new();
954 for msg in messages {
955 if msg.role == "tool" {
956 if let Some(call_id) = msg.tool_call_id {
959 out.push(json!({
960 "type": "function_call_output",
961 "call_id": call_id,
962 "output": msg.content.unwrap_or_default(),
963 }));
964 }
965 continue;
966 }
967 if msg.content_parts.is_some() || msg.content.is_some() || msg.tool_calls.is_none() {
971 let content = if let Some(parts) = msg.content_parts {
972 Value::Array(parts.into_iter().map(|part| match part {
973 ChatContentPart::Text { text } => json!({
974 "type": if msg.role == "assistant" { "output_text" } else { "input_text" },
975 "text": text,
976 }),
977 ChatContentPart::ImageUrl { image_url } => {
978 let mut part = json!({"type": "input_image", "image_url": image_url.url});
979 if let Some(detail) = image_url.detail {
980 part["detail"] = json!(detail);
981 }
982 part
983 }
984 ChatContentPart::File { file } => json!({
985 "type": "input_file",
986 "filename": file.filename,
987 "file_data": file.file_data,
988 }),
989 }).collect())
990 } else {
991 Value::String(msg.content.unwrap_or_default())
992 };
993 out.push(json!({"role": msg.role, "content": content}));
994 }
995 if let Some(tool_calls) = msg.tool_calls {
996 for call in tool_calls {
997 let mut item = json!({
1004 "type": "function_call",
1005 "call_id": call.id,
1006 "name": call.function.name,
1007 "arguments": call.function.arguments,
1008 });
1009 if call.id.starts_with("fc") {
1010 item["id"] = Value::from(call.id.clone());
1011 }
1012 out.push(item);
1013 }
1014 }
1015 }
1016 out
1017}
1018
1019#[derive(Clone, Debug, Eq, PartialEq)]
1020struct ResponsesStreamFailure {
1021 code: &'static str,
1022 message: String,
1025}
1026
1027impl ResponsesStreamFailure {
1028 fn is_retryable(&self) -> bool {
1032 matches!(
1033 self.code,
1034 "responses_capacity" | "responses_empty" | "responses_missing_terminal"
1035 )
1036 }
1037}
1038
1039const CAPACITY_ERROR_CODES: &[&str] = &[
1043 "rate_limit_exceeded",
1044 "server_overloaded",
1045 "overloaded",
1046 "capacity",
1047 "server_error",
1048];
1049
1050const CAPACITY_MESSAGE_PATTERNS: &[&str] = &[
1056 "at capacity",
1057 "high demand",
1058 "try again in a few minutes",
1059 "overloaded",
1060];
1061
1062fn is_capacity_failure(error_code: &str, message: Option<&str>) -> bool {
1065 if CAPACITY_ERROR_CODES.contains(&error_code) {
1066 return true;
1067 }
1068 message.is_some_and(|m| {
1069 let lower = m.to_ascii_lowercase();
1070 CAPACITY_MESSAGE_PATTERNS.iter().any(|p| lower.contains(p))
1071 })
1072}
1073
1074struct CodexSseDecoder {
1075 provider_label: &'static str,
1078 buffer: String,
1079 active_tools: Vec<CodexToolAccumulator>,
1080 completed_tools: Vec<ToolCall>,
1081 saw_model_event: bool,
1084 terminal_success: bool,
1088 terminal_failure: Option<ResponsesStreamFailure>,
1089 emitted_output: bool,
1090 observed_usage: Option<(u64, u64, u64)>,
1093}
1094
1095impl Default for CodexSseDecoder {
1096 fn default() -> Self {
1097 Self::for_provider("Codex")
1098 }
1099}
1100
1101#[derive(Default)]
1102struct CodexToolAccumulator {
1103 id: String,
1104 name: String,
1105 arguments: String,
1106 started: bool,
1107}
1108
1109fn parse_tool_arguments(raw: &str) -> Value {
1117 if raw.trim().is_empty() {
1118 return json!({});
1119 }
1120 match serde_json::from_str(raw) {
1121 Ok(v) => v,
1122 Err(e) => json!({ "__parse_error": format!("invalid tool input JSON: {}", e) }),
1123 }
1124}
1125
1126fn sanitize_error_identifier(raw: Option<&str>) -> &str {
1135 match raw {
1136 None => "absent",
1137 Some(s) => {
1138 let identifier_shaped = !s.is_empty()
1139 && s.len() <= 64
1140 && s.bytes()
1141 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.');
1142 if identifier_shaped
1143 && s.bytes()
1144 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
1145 {
1146 s
1148 } else if identifier_shaped {
1149 "unlisted_identifier"
1152 } else {
1153 "unsafe_or_freeform"
1154 }
1155 }
1156 }
1157}
1158
1159pub(crate) const RESPONSES_AUTH_SUFFIX: &str =
1163 " authentication rejected in stream. Sign in again or switch to an available account.";
1164pub(crate) const RESPONSES_QUOTA_SUFFIX: &str =
1165 " usage quota exhausted in stream. Add credit, wait for reset, or switch models.";
1166pub(crate) const RESPONSES_FAILED_SUFFIX: &str =
1167 " response failed in stream. Provider error details withheld because they can echo request content.";
1168pub(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.";
1169pub(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.";
1170pub(crate) const RESPONSES_INCOMPLETE_SUFFIX: &str =
1171 " response was incomplete. Retry the request or reduce the requested output/context size.";
1172pub(crate) const RESPONSES_EMPTY_SUFFIX: &str =
1173 " completed without text or tool output. Retry the request.";
1174pub(crate) const RESPONSES_MISSING_TERMINAL_SUFFIX: &str =
1175 " response stream ended without a terminal event. Retry the request.";
1176
1177fn user_message_for_terminal_failure(
1183 label: &str,
1184 error_kind: &str,
1185 error_code: &str,
1186 capacity: bool,
1187) -> String {
1188 if error_code == "context_length_exceeded" || error_kind == "context_length_exceeded" {
1191 return format!("{label}{RESPONSES_CONTEXT_SUFFIX}");
1192 }
1193 let classify = |value: &str| match value {
1196 "absent" | "error" => None,
1197 "invalid_api_key" | "authentication_error" | "invalid_authentication_error" => Some("auth"),
1198 "insufficient_quota" | "quota_exceeded" | "billing_error" => Some("quota"),
1199 "rate_limit_exceeded"
1200 | "rate_limit_error"
1201 | "server_overloaded"
1202 | "overloaded"
1203 | "capacity"
1204 | "server_error"
1205 | "api_error"
1206 | "overloaded_error" => Some("capacity"),
1207 _ => Some("blocked"),
1208 };
1209 let (kind, code) = (classify(error_kind), classify(error_code));
1210 if kind == Some("blocked")
1211 || code == Some("blocked")
1212 || (kind.is_some() && code.is_some() && kind != code)
1213 {
1214 return format!("{label}{RESPONSES_FAILED_SUFFIX}");
1215 }
1216 match code.or(kind) {
1217 Some("auth") => return format!("{label}{RESPONSES_AUTH_SUFFIX}"),
1218 Some("quota") => return format!("{label}{RESPONSES_QUOTA_SUFFIX}"),
1219 Some("capacity") => return format!("{label}{RESPONSES_CAPACITY_SUFFIX}"),
1220 None if capacity => return format!("{label}{RESPONSES_CAPACITY_SUFFIX}"),
1223 _ => {}
1224 }
1225 format!("{label}{RESPONSES_FAILED_SUFFIX}")
1226}
1227
1228impl CodexSseDecoder {
1229 fn for_provider(label: &'static str) -> Self {
1231 Self {
1232 provider_label: label,
1233 buffer: String::new(),
1234 active_tools: Vec::new(),
1235 completed_tools: Vec::new(),
1236 saw_model_event: false,
1237 terminal_success: false,
1238 terminal_failure: None,
1239 emitted_output: false,
1240 observed_usage: None,
1241 }
1242 }
1243
1244 fn emitted_any_output(&self) -> bool {
1248 self.emitted_output
1249 || !self.completed_tools.is_empty()
1250 || self.active_tools.iter().any(|tool| tool.started)
1251 }
1252
1253 fn push_line(
1254 &mut self,
1255 line: &str,
1256 tx: &mpsc::UnboundedSender<StreamEvent>,
1257 text_acc: &mut String,
1258 ) {
1259 let line = line.trim_end_matches('\r');
1260 if line.is_empty() {
1261 if !self.buffer.is_empty() {
1262 let payload = std::mem::take(&mut self.buffer);
1263 self.push_payload(&payload, tx, text_acc);
1264 }
1265 return;
1266 }
1267 let Some(data) = line.strip_prefix("data:").map(str::trim_start) else {
1268 return;
1269 };
1270 if data == "[DONE]" {
1271 self.terminal_success = true;
1272 self.finish();
1273 return;
1274 }
1275 self.buffer.push_str(data);
1276 }
1277
1278 fn push_payload(
1279 &mut self,
1280 payload: &str,
1281 tx: &mpsc::UnboundedSender<StreamEvent>,
1282 text_acc: &mut String,
1283 ) {
1284 let Ok(event) = serde_json::from_str::<Value>(payload) else {
1285 tracing::debug!(
1286 payload_bytes = payload.len(),
1287 "discarding malformed Responses SSE payload"
1288 );
1289 return;
1290 };
1291 let event_type = event
1292 .get("type")
1293 .and_then(Value::as_str)
1294 .unwrap_or_default();
1295 if matches!(
1296 event_type,
1297 "response.output_text.delta"
1298 | "response.output_item.added"
1299 | "response.function_call_arguments.delta"
1300 | "response.output_item.done"
1301 | "response.completed"
1302 | "response.done"
1303 | "response.failed"
1304 | "response.incomplete"
1305 | "error"
1306 ) {
1307 self.saw_model_event = true;
1308 }
1309 match event_type {
1310 "response.output_text.delta" => {
1311 if let Some(delta) = event.get("delta").and_then(Value::as_str) {
1312 if !delta.is_empty() {
1313 self.emitted_output = true;
1314 }
1315 text_acc.push_str(delta);
1316 let _ = tx.send(StreamEvent::Llm(crate::runtime::types::LlmEvent::Text(
1317 delta.to_string(),
1318 )));
1319 }
1320 }
1321 "response.output_item.added" => {
1322 if let Some(item) = event.get("item") {
1323 let idx = event
1324 .get("output_index")
1325 .and_then(Value::as_u64)
1326 .unwrap_or(0) as usize;
1327 self.add_tool_from_item(idx, item, tx);
1328 }
1329 }
1330 "response.function_call_arguments.delta" => {
1331 let idx = event
1332 .get("output_index")
1333 .and_then(Value::as_u64)
1334 .unwrap_or(0) as usize;
1335 let delta = event
1336 .get("delta")
1337 .and_then(Value::as_str)
1338 .unwrap_or_default();
1339 if !delta.is_empty() {
1340 let tool = self.ensure_tool(idx);
1341 tool.arguments.push_str(delta);
1342 let tool_id = tool.id.clone();
1343 let _ = tx.send(StreamEvent::Llm(
1344 crate::runtime::types::LlmEvent::ToolUseDelta {
1345 tool_id,
1346 delta: delta.to_string(),
1347 },
1348 ));
1349 }
1350 }
1351 "response.output_item.done" => {
1352 if let Some(item) = event.get("item") {
1353 let idx = event
1354 .get("output_index")
1355 .and_then(Value::as_u64)
1356 .unwrap_or(0) as usize;
1357 self.complete_tool_from_item(idx, item, tx);
1358 }
1359 }
1360 "response.completed" | "response.done" => {
1361 self.push_usage(&event, tx);
1362 self.terminal_success = true;
1363 self.finish();
1364 }
1365 "response.failed" | "error" => {
1366 self.push_usage(&event, tx);
1367 let error_obj = event
1378 .get("response")
1379 .and_then(|r| r.get("error"))
1380 .or_else(|| event.get("error"))
1381 .filter(|e| e.is_object());
1382 let error_kind = sanitize_error_identifier(
1383 error_obj
1384 .and_then(|e| e.get("type"))
1385 .and_then(Value::as_str),
1386 );
1387 let error_code = sanitize_error_identifier(
1388 error_obj
1389 .and_then(|e| e.get("code"))
1390 .or_else(|| event.get("code"))
1391 .and_then(Value::as_str),
1392 );
1393 let capacity = is_capacity_failure(
1396 error_code,
1397 error_obj
1398 .and_then(|e| e.get("message"))
1399 .or_else(|| event.get("message"))
1400 .and_then(Value::as_str),
1401 );
1402 tracing::warn!(
1403 provider = self.provider_label,
1404 event_type,
1405 error_kind,
1406 error_code,
1407 capacity,
1408 "responses stream terminal failure (provider message withheld)"
1409 );
1410 let message = user_message_for_terminal_failure(
1411 self.provider_label,
1412 error_kind,
1413 error_code,
1414 capacity,
1415 );
1416 self.terminal_failure = Some(ResponsesStreamFailure {
1417 code: if message
1420 == format!("{}{RESPONSES_CAPACITY_SUFFIX}", self.provider_label)
1421 {
1422 "responses_capacity"
1423 } else {
1424 "responses_failed"
1425 },
1426 message,
1427 });
1428 self.finish();
1429 }
1430 "response.incomplete" => {
1431 self.push_usage(&event, tx);
1432 let reason = sanitize_error_identifier(
1433 event
1434 .get("response")
1435 .and_then(|r| r.get("incomplete_details"))
1436 .and_then(|d| d.get("reason"))
1437 .and_then(Value::as_str),
1438 );
1439 tracing::warn!(
1440 provider = self.provider_label,
1441 reason,
1442 "responses stream terminal incomplete"
1443 );
1444 self.terminal_failure = Some(ResponsesStreamFailure {
1445 code: "responses_incomplete",
1446 message: format!("{}{RESPONSES_INCOMPLETE_SUFFIX}", self.provider_label),
1447 });
1448 self.finish();
1449 }
1450 _ => {
1451 if !event_type.is_empty() {
1452 tracing::debug!(event_type, "ignoring unknown Responses SSE event");
1453 }
1454 }
1455 }
1456 }
1457
1458 fn terminal_result(&self) -> Result<(), ResponsesStreamFailure> {
1459 if let Some(failure) = &self.terminal_failure {
1460 return Err(failure.clone());
1461 }
1462 if self.terminal_success {
1463 if self.emitted_output || !self.completed_tools.is_empty() {
1464 Ok(())
1465 } else {
1466 Err(ResponsesStreamFailure {
1467 code: "responses_empty",
1468 message: format!("{}{RESPONSES_EMPTY_SUFFIX}", self.provider_label),
1469 })
1470 }
1471 } else {
1472 Err(ResponsesStreamFailure {
1473 code: "responses_missing_terminal",
1474 message: format!("{}{RESPONSES_MISSING_TERMINAL_SUFFIX}", self.provider_label),
1475 })
1476 }
1477 }
1478
1479 fn ensure_tool(&mut self, idx: usize) -> &mut CodexToolAccumulator {
1480 const MAX_TOOLS: usize = 4096;
1485 let idx = idx.min(MAX_TOOLS - 1);
1486 while self.active_tools.len() <= idx {
1487 self.active_tools.push(CodexToolAccumulator::default());
1488 }
1489 &mut self.active_tools[idx]
1490 }
1491
1492 fn add_tool_from_item(
1493 &mut self,
1494 idx: usize,
1495 item: &Value,
1496 tx: &mpsc::UnboundedSender<StreamEvent>,
1497 ) {
1498 if item.get("type").and_then(Value::as_str) != Some("function_call") {
1499 return;
1500 }
1501 let tool = self.ensure_tool(idx);
1502 if let Some(id) = item
1503 .get("call_id")
1504 .or_else(|| item.get("id"))
1505 .and_then(Value::as_str)
1506 {
1507 tool.id = id.to_string();
1508 }
1509 if let Some(name) = item.get("name").and_then(Value::as_str) {
1510 tool.name = name.to_string();
1511 }
1512 if !tool.started && !tool.name.is_empty() {
1513 tool.started = true;
1514 let _ = tx.send(StreamEvent::Llm(
1515 crate::runtime::types::LlmEvent::ToolUseStart {
1516 tool_name: tool.name.clone(),
1517 tool_id: tool.id.clone(),
1518 },
1519 ));
1520 }
1521 }
1522
1523 fn complete_tool_from_item(
1524 &mut self,
1525 idx: usize,
1526 item: &Value,
1527 tx: &mpsc::UnboundedSender<StreamEvent>,
1528 ) {
1529 if item.get("type").and_then(Value::as_str) != Some("function_call") {
1530 return;
1531 }
1532 let tool = self.ensure_tool(idx);
1533 if let Some(id) = item
1534 .get("call_id")
1535 .or_else(|| item.get("id"))
1536 .and_then(Value::as_str)
1537 {
1538 tool.id = id.to_string();
1539 }
1540 if let Some(name) = item.get("name").and_then(Value::as_str) {
1541 tool.name = name.to_string();
1542 }
1543 if let Some(arguments) = item.get("arguments").and_then(Value::as_str) {
1544 tool.arguments = arguments.to_string();
1545 }
1546 if !tool.started && !tool.name.is_empty() {
1547 tool.started = true;
1548 let _ = tx.send(StreamEvent::Llm(
1549 crate::runtime::types::LlmEvent::ToolUseStart {
1550 tool_name: tool.name.clone(),
1551 tool_id: tool.id.clone(),
1552 },
1553 ));
1554 }
1555 let completed = if !tool.id.is_empty() && !tool.name.is_empty() {
1556 Some(ToolCall {
1557 id: tool.id.clone(),
1558 kind: "function".to_string(),
1559 function: super::types::FunctionCall {
1560 name: tool.name.clone(),
1561 arguments: tool.arguments.clone(),
1562 },
1563 })
1564 } else {
1565 None
1566 };
1567 if let Some(call) = completed {
1568 if self.completed_tools.iter().any(|done| done.id == call.id) {
1569 return;
1570 }
1571 self.emitted_output = true;
1572 let input = parse_tool_arguments(&call.function.arguments);
1580 let _ = tx.send(StreamEvent::Llm(crate::runtime::types::LlmEvent::ToolUse {
1581 tool_name: call.function.name.clone(),
1582 tool_id: call.id.clone(),
1583 input,
1584 }));
1585 self.completed_tools.push(ToolCall {
1586 id: call.id,
1587 kind: call.kind,
1588 function: call.function,
1589 });
1590 }
1591 }
1592
1593 fn push_usage(&mut self, event: &Value, tx: &mpsc::UnboundedSender<StreamEvent>) {
1594 let usage = event
1595 .get("response")
1596 .and_then(|r| r.get("usage"))
1597 .or_else(|| event.get("usage"));
1598 let input = usage
1599 .and_then(|u| u.get("input_tokens"))
1600 .and_then(Value::as_u64)
1601 .unwrap_or(0);
1602 let output = usage
1603 .and_then(|u| u.get("output_tokens"))
1604 .and_then(Value::as_u64)
1605 .unwrap_or(0);
1606 let cached = usage
1613 .and_then(|u| u.pointer("/input_tokens_details/cached_tokens"))
1614 .and_then(Value::as_u64)
1615 .unwrap_or(0)
1616 .min(input);
1617 if input > 0 || output > 0 {
1618 if self.observed_usage.is_none() {
1621 self.observed_usage = Some((input, output, cached));
1622 }
1623 let _ = tx.send(StreamEvent::Session(
1624 crate::runtime::types::SessionEvent::Usage {
1625 input_tokens: input - cached,
1626 output_tokens: output,
1627 cache_read_input_tokens: cached,
1628 cache_creation_input_tokens: 0,
1629 cache_creation_5m: None,
1630 cache_creation_1h: None,
1631 model: None,
1632 },
1633 ));
1634 }
1635 }
1636
1637 fn trace_usage(&self) -> Option<crate::runtime::trace::UsageMeta> {
1639 self.observed_usage
1640 .map(|(input, output, cached)| tr::provider_usage(input, output, cached))
1641 }
1642
1643 fn finish(&mut self) {
1644 for tool in self.active_tools.drain(..) {
1645 if !tool.id.is_empty()
1646 && !tool.name.is_empty()
1647 && !self.completed_tools.iter().any(|done| done.id == tool.id)
1648 {
1649 self.emitted_output = true;
1650 self.completed_tools.push(ToolCall {
1651 id: tool.id,
1652 kind: "function".to_string(),
1653 function: super::types::FunctionCall {
1654 name: tool.name,
1655 arguments: tool.arguments,
1656 },
1657 });
1658 }
1659 }
1660 }
1661}
1662
1663fn handle_events(
1664 events: &[OaiEvent],
1665 tx: &mpsc::UnboundedSender<StreamEvent>,
1666 text_acc: &mut String,
1667 tool_blocks: &mut Vec<Value>,
1668 name_map: &translate::ToolNameMap,
1669 suppress_stream_deltas: bool,
1670) {
1671 for ev in events {
1672 if let OaiEvent::TextDelta(t) = ev {
1673 text_acc.push_str(t);
1674 }
1675 if let OaiEvent::ToolCallsComplete { calls, .. } = ev {
1676 tool_blocks.extend(translate::tool_calls_to_content_blocks(calls, name_map));
1677 }
1678 if !suppress_stream_deltas {
1679 if let Some(se) = translate::oai_event_to_llm(ev) {
1680 let _ = tx.send(se);
1681 }
1682 }
1683 }
1684}
1685
1686#[cfg(test)]
1687mod codex_input_messages_tests {
1688 use super::super::types::{ChatMessage, FunctionCall, ToolCall};
1704 use super::*;
1705
1706 fn sample_tool_call() -> ToolCall {
1707 ToolCall {
1708 id: "call_nZYquCuGUh8Qs9H51dwHMDgs".to_string(),
1709 kind: "function".to_string(),
1710 function: FunctionCall {
1711 name: "bash".to_string(),
1712 arguments: r#"{"command":"ls"}"#.to_string(),
1713 },
1714 }
1715 }
1716
1717 fn translated_input(messages: Vec<Value>) -> Vec<Value> {
1718 let messages: Vec<crate::SharedMessage> =
1719 messages.into_iter().map(std::sync::Arc::new).collect();
1720 codex_input_messages(translate::messages_to_oai(
1721 &messages,
1722 &None,
1723 &translate::ToolNameMap::default(),
1724 ))
1725 }
1726
1727 #[test]
1728 fn image_text_document_and_pdf_exact_responses_wire() {
1729 let out = translated_input(vec![json!({"role":"user","content":[
1730 {"type":"text","text":"Compare these."},
1731 {"type":"image","source":{"type":"base64","media_type":"image/png","data":"aW1hZ2U="}},
1732 {"type":"document","title":"notes.txt","source":{"type":"text","media_type":"text/plain","data":"Text document\ncontents"}},
1733 {"type":"document","title":"report.pdf","source":{"type":"base64","media_type":"application/pdf","data":"JVBERi0="}},
1734 {"type":"text","text":"Then summarize."}
1735 ]})]);
1736 assert_eq!(
1737 out,
1738 vec![json!({"role":"user","content":[
1739 {"type":"input_text","text":"Compare these."},
1740 {"type":"input_text","text":"[attachment origin=user type=image media_type=\"image/png\"; lower-authority data]\n"},
1741 {"type":"input_image","image_url":"data:image/png;base64,aW1hZ2U="},
1742 {"type":"input_text","text":"[/attachment]\n"},
1743 {"type":"input_text","text":"[attachment origin=user type=document media_type=\"text/plain\" filename=\"notes.txt\"; lower-authority data]\n"},
1744 {"type":"input_text","text":"Text document\ncontents"},
1745 {"type":"input_text","text":"[/attachment]\n"},
1746 {"type":"input_text","text":"[attachment origin=user type=document media_type=\"application/pdf\" filename=\"report.pdf\"; lower-authority data]\n"},
1747 {"type":"input_file","filename":"report.pdf","file_data":"data:application/pdf;base64,JVBERi0="},
1748 {"type":"input_text","text":"[/attachment]\n"},
1749 {"type":"input_text","text":"Then summarize."}
1750 ]})]
1751 );
1752 }
1753
1754 #[test]
1755 fn mixed_assistant_text_and_all_tool_outputs_precede_lifted_responses_media() {
1756 let out = translated_input(vec![
1757 json!({"role":"assistant","content":[
1758 {"type":"text","text":"Reading both."},
1759 {"type":"tool_use","id":"t1","name":"read","input":{}},
1760 {"type":"tool_use","id":"t2","name":"read","input":{}}
1761 ]}),
1762 json!({"role":"user","content":[
1763 {"type":"tool_result","tool_use_id":"t1","content":[
1764 {"type":"text","text":"First result"},
1765 {"type":"image","source":{"type":"base64","media_type":"image/png","data":"aW1hZ2U="}},
1766 {"type":"document","title":"notes.txt","source":{"type":"text","media_type":"text/plain","data":"DOCUMENT_BODY_SENTINEL"}}
1767 ]},
1768 {"type":"tool_result","tool_use_id":"t2","content":[
1769 {"type":"text","text":"Second result"},
1770 {"type":"document","title":"report.pdf","source":{"type":"base64","media_type":"application/pdf","data":"JVBERi0="}}
1771 ]}
1772 ]}),
1773 ]);
1774 assert_eq!(
1775 out,
1776 vec![
1777 json!({"role":"assistant","content":"Reading both."}),
1778 json!({"type":"function_call","call_id":"t1","name":"read","arguments":"{}"}),
1779 json!({"type":"function_call","call_id":"t2","name":"read","arguments":"{}"}),
1780 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"}),
1781 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"}),
1782 json!({"role":"user","content":[
1783 {"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"},
1784 {"type":"input_image","image_url":"data:image/png;base64,aW1hZ2U="},
1785 {"type":"input_text","text":"[/attachment]\n"},
1786 {"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"},
1787 {"type":"input_text","text":"DOCUMENT_BODY_SENTINEL"},
1788 {"type":"input_text","text":"[/attachment]\n"},
1789 {"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"},
1790 {"type":"input_file","filename":"report.pdf","file_data":"data:application/pdf;base64,JVBERi0="},
1791 {"type":"input_text","text":"[/attachment]\n"}
1792 ]})
1793 ]
1794 );
1795 for output in out
1796 .iter()
1797 .filter(|item| item["type"] == "function_call_output")
1798 {
1799 let text = output["output"].as_str().unwrap();
1800 for sentinel in [
1801 "aW1hZ2U=",
1802 "JVBERi0=",
1803 "DOCUMENT_BODY_SENTINEL",
1804 "\"source\"",
1805 ] {
1806 assert!(
1807 !text.contains(sentinel),
1808 "tool output must contain only text and metadata"
1809 );
1810 }
1811 }
1812 }
1813
1814 #[test]
1815 fn text_only_responses_input_keeps_strings_and_mixed_assistant_text() {
1816 let mut assistant = ChatMessage::assistant_tool_calls(vec![sample_tool_call()]);
1817 assistant.content = Some("Checking.".into());
1818 let out = codex_input_messages(vec![
1819 ChatMessage::system("rules"),
1820 ChatMessage::user("question"),
1821 assistant,
1822 ChatMessage::tool_result("call_nZYquCuGUh8Qs9H51dwHMDgs", "bash", "done"),
1823 ChatMessage::assistant("answer"),
1824 ]);
1825 assert_eq!(
1826 out,
1827 vec![
1828 json!({"role":"system","content":"rules"}),
1829 json!({"role":"user","content":"question"}),
1830 json!({"role":"assistant","content":"Checking."}),
1831 json!({"type":"function_call","call_id":"call_nZYquCuGUh8Qs9H51dwHMDgs","name":"bash","arguments":"{\"command\":\"ls\"}"}),
1832 json!({"type":"function_call_output","call_id":"call_nZYquCuGUh8Qs9H51dwHMDgs","output":"done"}),
1833 json!({"role":"assistant","content":"answer"}),
1834 ]
1835 );
1836 }
1837
1838 #[test]
1839 fn codex_instructions_appends_autonomous_loop_policy() {
1840 let instructions = codex_instructions(&Some("Project-specific rules.".to_string()));
1841 assert!(instructions.contains("Project-specific rules."));
1842 assert!(instructions.contains("Do not stop at phase boundaries"));
1843 assert!(instructions.contains("Do not ask the user whether to continue"));
1844 assert!(
1845 instructions.contains("continue autonomously until the full requested job is complete")
1846 );
1847 }
1848
1849 #[test]
1850 fn function_call_input_omits_non_fc_id() {
1851 let messages = vec![ChatMessage::assistant_tool_calls(vec![sample_tool_call()])];
1852 let out = codex_input_messages(messages);
1853 assert_eq!(out.len(), 1, "one tool_call → one input item");
1854 let item = &out[0];
1855 assert_eq!(
1856 item.get("type").and_then(Value::as_str),
1857 Some("function_call")
1858 );
1859 assert!(
1860 item.get("id").is_none(),
1861 "must not echo a non-`fc_` id back; got {:?}",
1862 item.get("id"),
1863 );
1864 assert_eq!(
1865 item.get("call_id").and_then(Value::as_str),
1866 Some("call_nZYquCuGUh8Qs9H51dwHMDgs"),
1867 );
1868 assert_eq!(item.get("name").and_then(Value::as_str), Some("bash"));
1869 }
1870
1871 #[test]
1872 fn function_call_input_keeps_real_fc_id() {
1873 let mut call = sample_tool_call();
1876 call.id = "fc_abc123".to_string();
1877 let messages = vec![ChatMessage::assistant_tool_calls(vec![call])];
1878 let out = codex_input_messages(messages);
1879 let item = &out[0];
1880 assert_eq!(item.get("id").and_then(Value::as_str), Some("fc_abc123"));
1881 assert_eq!(
1882 item.get("call_id").and_then(Value::as_str),
1883 Some("fc_abc123")
1884 );
1885 }
1886
1887 #[test]
1888 fn function_call_output_round_trips_call_id() {
1889 let messages = vec![ChatMessage::tool_result(
1891 "call_nZYquCuGUh8Qs9H51dwHMDgs",
1892 "bash",
1893 "total 0",
1894 )];
1895 let out = codex_input_messages(messages);
1896 let item = &out[0];
1897 assert_eq!(
1898 item.get("type").and_then(Value::as_str),
1899 Some("function_call_output"),
1900 );
1901 assert_eq!(
1902 item.get("call_id").and_then(Value::as_str),
1903 Some("call_nZYquCuGUh8Qs9H51dwHMDgs"),
1904 );
1905 assert_eq!(item.get("output").and_then(Value::as_str), Some("total 0"));
1906 }
1907}
1908
1909#[cfg(test)]
1910mod codex_decoder_tests {
1911 use super::*;
1918 use crate::runtime::types::{LlmEvent, SessionEvent, StreamEvent};
1919
1920 fn collect_events(rx: &mut mpsc::UnboundedReceiver<StreamEvent>) -> Vec<StreamEvent> {
1921 let mut out = Vec::new();
1922 while let Ok(ev) = rx.try_recv() {
1923 out.push(ev);
1924 }
1925 out
1926 }
1927
1928 fn drive(lines: &[&str]) -> (CodexSseDecoder, String, Vec<StreamEvent>) {
1929 let (tx, mut rx) = mpsc::unbounded_channel();
1930 let mut decoder = CodexSseDecoder::default();
1931 let mut text_acc = String::new();
1932 for line in lines {
1933 decoder.push_line(line, &tx, &mut text_acc);
1934 }
1935 let events = collect_events(&mut rx);
1936 (decoder, text_acc, events)
1937 }
1938
1939 #[test]
1940 fn hostile_output_index_does_not_grow_active_tools_unbounded() {
1941 let lines = [
1946 r#"data: {"type":"response.function_call_arguments.delta","output_index":1000000000,"delta":"x"}"#,
1947 "",
1948 ];
1949 let (decoder, _text, _events) = drive(&lines);
1950 assert!(
1951 decoder.active_tools.len() <= 4096,
1952 "active_tools grew to {} — output_index not bounded",
1953 decoder.active_tools.len()
1954 );
1955 }
1956
1957 #[test]
1958 fn text_delta_aggregates_into_text_acc_and_emits_text_events() {
1959 let lines = [
1960 r#"data: {"type":"response.output_text.delta","delta":"Hello, "}"#,
1961 "",
1962 r#"data: {"type":"response.output_text.delta","delta":"world!"}"#,
1963 "",
1964 ];
1965 let (_decoder, text_acc, events) = drive(&lines);
1966 assert_eq!(text_acc, "Hello, world!");
1967 let texts: Vec<_> = events
1968 .iter()
1969 .filter_map(|e| match e {
1970 StreamEvent::Llm(LlmEvent::Text(t)) => Some(t.as_str()),
1971 _ => None,
1972 })
1973 .collect();
1974 assert_eq!(texts, vec!["Hello, ", "world!"]);
1975 }
1976
1977 #[test]
1978 fn single_function_call_completes_via_output_item_done() {
1979 let lines = [
1980 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_abc","name":"bash"}}"#,
1981 "",
1982 r#"data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"{\"cmd\""}"#,
1983 "",
1984 r#"data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":":\"ls\"}"}"#,
1985 "",
1986 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_abc","name":"bash","arguments":"{\"cmd\":\"ls\"}"}}"#,
1987 "",
1988 ];
1989 let (decoder, _text, events) = drive(&lines);
1990
1991 assert_eq!(decoder.completed_tools.len(), 1);
1992 let tool = &decoder.completed_tools[0];
1993 assert_eq!(tool.id, "call_abc");
1994 assert_eq!(tool.function.name, "bash");
1995 assert_eq!(tool.function.arguments, r#"{"cmd":"ls"}"#);
1996
1997 let starts: Vec<_> = events
1999 .iter()
2000 .filter_map(|e| match e {
2001 StreamEvent::Llm(LlmEvent::ToolUseStart { tool_name, tool_id }) => {
2002 Some((tool_name.as_str(), tool_id.as_str()))
2003 }
2004 _ => None,
2005 })
2006 .collect();
2007 assert_eq!(
2008 starts,
2009 vec![("bash", "call_abc")],
2010 "exactly one ToolUseStart with correct tool_id"
2011 );
2012
2013 let deltas: Vec<_> = events
2016 .iter()
2017 .filter_map(|e| match e {
2018 StreamEvent::Llm(LlmEvent::ToolUseDelta { tool_id, delta }) => {
2019 Some((tool_id.as_str(), delta.as_str()))
2020 }
2021 _ => None,
2022 })
2023 .collect();
2024 assert_eq!(
2025 deltas,
2026 vec![("call_abc", r#"{"cmd""#), ("call_abc", r#":"ls"}"#)]
2027 );
2028 }
2029
2030 #[test]
2031 fn parallel_tool_calls_indexed_separately() {
2032 let lines = [
2033 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"bash"}}"#,
2034 "",
2035 r#"data: {"type":"response.output_item.added","output_index":1,"item":{"type":"function_call","call_id":"call_2","name":"read"}}"#,
2036 "",
2037 r#"data: {"type":"response.function_call_arguments.delta","output_index":1,"delta":"{\"path\":\"a\"}"}"#,
2038 "",
2039 r#"data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"{\"cmd\":\"ls\"}"}"#,
2040 "",
2041 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"bash","arguments":"{\"cmd\":\"ls\"}"}}"#,
2042 "",
2043 r#"data: {"type":"response.output_item.done","output_index":1,"item":{"type":"function_call","call_id":"call_2","name":"read","arguments":"{\"path\":\"a\"}"}}"#,
2044 "",
2045 ];
2046 let (decoder, _text, _events) = drive(&lines);
2047
2048 assert_eq!(decoder.completed_tools.len(), 2);
2049 let mut by_id: std::collections::BTreeMap<&str, &ToolCall> =
2050 std::collections::BTreeMap::new();
2051 for tool in &decoder.completed_tools {
2052 by_id.insert(tool.id.as_str(), tool);
2053 }
2054 assert_eq!(by_id["call_1"].function.name, "bash");
2055 assert_eq!(by_id["call_1"].function.arguments, r#"{"cmd":"ls"}"#);
2056 assert_eq!(by_id["call_2"].function.name, "read");
2057 assert_eq!(by_id["call_2"].function.arguments, r#"{"path":"a"}"#);
2058 }
2059
2060 #[test]
2061 fn output_item_done_emits_tool_use_event() {
2062 let lines = [
2069 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_abc","name":"bash"}}"#,
2070 "",
2071 r#"data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"{\"command\":\"ls\"}"}"#,
2072 "",
2073 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_abc","name":"bash","arguments":"{\"command\":\"ls\"}"}}"#,
2074 "",
2075 ];
2076 let (_decoder, _text, events) = drive(&lines);
2077
2078 let tool_uses: Vec<_> = events
2079 .iter()
2080 .filter_map(|e| match e {
2081 StreamEvent::Llm(LlmEvent::ToolUse {
2082 tool_name,
2083 tool_id,
2084 input,
2085 }) => Some((tool_name.as_str(), tool_id.as_str(), input.clone())),
2086 _ => None,
2087 })
2088 .collect();
2089 assert_eq!(
2090 tool_uses.len(),
2091 1,
2092 "expected exactly one ToolUse finalize event"
2093 );
2094 assert_eq!(tool_uses[0].0, "bash");
2095 assert_eq!(tool_uses[0].1, "call_abc");
2096 assert_eq!(
2097 tool_uses[0].2,
2098 serde_json::json!({"command": "ls"}),
2099 "input must be parsed as a JSON Value, not a string"
2100 );
2101 }
2102
2103 #[test]
2104 fn parallel_tool_calls_emit_tool_use_per_index() {
2105 let lines = [
2109 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"bash"}}"#,
2110 "",
2111 r#"data: {"type":"response.output_item.added","output_index":1,"item":{"type":"function_call","call_id":"call_2","name":"read"}}"#,
2112 "",
2113 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"bash","arguments":"{\"command\":\"ls\"}"}}"#,
2114 "",
2115 r#"data: {"type":"response.output_item.done","output_index":1,"item":{"type":"function_call","call_id":"call_2","name":"read","arguments":"{\"path\":\"a\"}"}}"#,
2116 "",
2117 ];
2118 let (_decoder, _text, events) = drive(&lines);
2119
2120 let tool_uses: Vec<_> = events
2121 .iter()
2122 .filter_map(|e| match e {
2123 StreamEvent::Llm(LlmEvent::ToolUse {
2124 tool_name,
2125 tool_id,
2126 input,
2127 }) => Some((tool_name.clone(), tool_id.clone(), input.clone())),
2128 _ => None,
2129 })
2130 .collect();
2131
2132 assert_eq!(tool_uses.len(), 2, "one ToolUse finalize per parallel call");
2133 let by_id: std::collections::BTreeMap<&str, &(String, String, serde_json::Value)> =
2134 tool_uses.iter().map(|t| (t.1.as_str(), t)).collect();
2135 assert_eq!(by_id["call_1"].0, "bash");
2136 assert_eq!(by_id["call_1"].2, serde_json::json!({"command": "ls"}));
2137 assert_eq!(by_id["call_2"].0, "read");
2138 assert_eq!(by_id["call_2"].2, serde_json::json!({"path": "a"}));
2139 }
2140
2141 #[test]
2142 fn malformed_arguments_emit_tool_use_with_parse_error() {
2143 let lines = [
2148 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_bad","name":"bash"}}"#,
2149 "",
2150 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_bad","name":"bash","arguments":"{not json"}}"#,
2151 "",
2152 ];
2153 let (_decoder, _text, events) = drive(&lines);
2154
2155 let tool_use = events.iter().find_map(|e| match e {
2156 StreamEvent::Llm(LlmEvent::ToolUse { input, .. }) => Some(input.clone()),
2157 _ => None,
2158 });
2159 let input = tool_use.expect("ToolUse event missing");
2160 assert!(
2161 input.get("__parse_error").and_then(Value::as_str).is_some(),
2162 "malformed arguments must surface __parse_error, got {input}"
2163 );
2164 }
2165
2166 #[test]
2167 fn response_completed_emits_usage_event() {
2168 let lines = [
2169 r#"data: {"type":"response.completed","response":{"usage":{"input_tokens":42,"output_tokens":17}}}"#,
2170 "",
2171 ];
2172 let (_decoder, _text, events) = drive(&lines);
2173 let usage = events.iter().find_map(|e| match e {
2174 StreamEvent::Session(SessionEvent::Usage {
2175 input_tokens,
2176 output_tokens,
2177 ..
2178 }) => Some((*input_tokens, *output_tokens)),
2179 _ => None,
2180 });
2181 assert_eq!(usage, Some((42, 17)));
2182 }
2183
2184 #[test]
2190 fn response_completed_surfaces_cached_tokens_without_double_count() {
2191 let lines = [
2192 r#"data: {"type":"response.completed","response":{"usage":{"input_tokens":1000,"input_tokens_details":{"cached_tokens":900},"output_tokens":17}}}"#,
2193 "",
2194 ];
2195 let (_decoder, _text, events) = drive(&lines);
2196 let usage = events.iter().find_map(|e| match e {
2197 StreamEvent::Session(SessionEvent::Usage {
2198 input_tokens,
2199 output_tokens,
2200 cache_read_input_tokens,
2201 ..
2202 }) => Some((*input_tokens, *output_tokens, *cache_read_input_tokens)),
2203 _ => None,
2204 });
2205 assert_eq!(
2206 usage,
2207 Some((100, 17, 900)),
2208 "input must exclude the cached slice; cache_read must carry it"
2209 );
2210 }
2211
2212 #[test]
2213 fn response_completed_with_zero_usage_emits_nothing() {
2214 let lines = [
2215 r#"data: {"type":"response.output_text.delta","delta":"ok"}"#,
2216 "",
2217 r#"data: {"type":"response.completed","response":{"usage":{"input_tokens":0,"output_tokens":0}}}"#,
2218 "",
2219 ];
2220 let (decoder, _text, events) = drive(&lines);
2221 let any_usage = events
2222 .iter()
2223 .any(|e| matches!(e, StreamEvent::Session(SessionEvent::Usage { .. })));
2224 assert!(!any_usage, "zero-token usage should be suppressed");
2225 assert_eq!(decoder.terminal_result(), Ok(()));
2226 }
2227
2228 #[test]
2229 fn response_failed_is_a_terminal_failure_without_retaining_provider_text() {
2230 let lines = [
2231 r#"data: {"type":"response.failed","response":{"error":{"type":"server_error","message":"ECHOED:secret prompt"}}}"#,
2232 "",
2233 ];
2234 let (decoder, text, events) = drive(&lines);
2235 let failure = decoder.terminal_result().expect_err("must fail");
2236 assert_eq!(failure.code, "responses_capacity");
2238 assert!(failure.is_retryable());
2239 assert_eq!(failure.message, format!("Codex{RESPONSES_CAPACITY_SUFFIX}"));
2240 assert!(!failure.message.contains("ECHOED"));
2241 assert!(text.is_empty());
2242 assert!(events.is_empty());
2243 }
2244
2245 #[test]
2246 fn top_level_error_event_is_a_terminal_failure() {
2247 let lines = [
2248 r#"data: {"type":"error","error":{"type":"server_error","message":"private"}}"#,
2249 "",
2250 ];
2251 let (decoder, _text, _events) = drive(&lines);
2252 assert_eq!(
2253 decoder.terminal_result().expect_err("must fail").code,
2254 "responses_capacity"
2255 );
2256 }
2257
2258 #[test]
2259 fn response_incomplete_is_not_misreported_as_empty_success() {
2260 let lines = [
2261 r#"data: {"type":"response.incomplete","response":{"incomplete_details":{"reason":"max_output_tokens"}}}"#,
2262 "",
2263 ];
2264 let (decoder, _text, _events) = drive(&lines);
2265 let failure = decoder.terminal_result().expect_err("must fail");
2266 assert_eq!(failure.code, "responses_incomplete");
2267 assert!(failure.message.contains("incomplete"));
2268 assert!(!failure.message.contains("max_output_tokens"));
2269 }
2270
2271 #[test]
2272 fn completed_without_text_or_tools_fails_at_transport_boundary() {
2273 let lines = [
2274 r#"data: {"type":"response.completed","response":{"usage":{"input_tokens":7,"output_tokens":0}}}"#,
2275 "",
2276 ];
2277 let (decoder, _text, _events) = drive(&lines);
2278 assert_eq!(
2279 decoder.terminal_result().expect_err("must fail").code,
2280 "responses_empty"
2281 );
2282 }
2283
2284 #[test]
2285 fn eof_without_terminal_event_fails_closed() {
2286 let lines = [
2287 r#"data: {"type":"response.created","response":{"id":"resp_123"}}"#,
2288 "",
2289 ];
2290 let (decoder, _text, _events) = drive(&lines);
2291 assert_eq!(
2292 decoder.terminal_result().expect_err("must fail").code,
2293 "responses_missing_terminal"
2294 );
2295 }
2296
2297 #[test]
2301 fn error_identifier_sanitizer_never_passes_freeform_text() {
2302 assert_eq!(sanitize_error_identifier(None), "absent");
2303 assert_eq!(
2304 sanitize_error_identifier(Some("rate_limit_exceeded")),
2305 "rate_limit_exceeded"
2306 );
2307 assert_eq!(
2308 sanitize_error_identifier(Some("context_length_exceeded")),
2309 "context_length_exceeded"
2310 );
2311 assert_eq!(
2312 sanitize_error_identifier(Some("some_new_code2")),
2313 "some_new_code2"
2314 );
2315 assert_eq!(
2317 sanitize_error_identifier(Some("sk-Abc123XYZ")),
2318 "unlisted_identifier"
2319 );
2320 assert_eq!(
2321 sanitize_error_identifier(Some("Server.Error")),
2322 "unlisted_identifier"
2323 );
2324 assert_eq!(
2325 sanitize_error_identifier(Some("ECHOED: user secret prompt")),
2326 "unsafe_or_freeform"
2327 );
2328 assert_eq!(sanitize_error_identifier(Some("")), "unsafe_or_freeform");
2329 let long = "a".repeat(65);
2330 assert_eq!(sanitize_error_identifier(Some(&long)), "unsafe_or_freeform");
2331 }
2332
2333 #[test]
2337 fn terminal_failure_user_message_is_specific_only_for_known_codes() {
2338 assert!(user_message_for_terminal_failure(
2339 "Codex",
2340 "invalid_request_error",
2341 "context_length_exceeded",
2342 false
2343 )
2344 .contains("/compact"));
2345 assert!(user_message_for_terminal_failure(
2347 "Codex",
2348 "context_length_exceeded",
2349 "absent",
2350 false
2351 )
2352 .contains("context window"));
2353 let default = user_message_for_terminal_failure(
2355 "Codex",
2356 "server_error",
2357 "unlisted_identifier",
2358 false,
2359 );
2360 assert!(default.starts_with("Codex response failed in stream."));
2361 assert!(!default.contains("/compact"));
2362 let capacity = user_message_for_terminal_failure("xAI", "absent", "absent", true);
2364 assert_eq!(
2365 capacity,
2366 "xAI reports the model is at capacity (retries exhausted). Try again in a few minutes or switch models with /model."
2367 );
2368 }
2369
2370 #[test]
2375 fn xai_flat_capacity_error_is_retryable_capacity_failure() {
2376 let lines = [
2377 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}"#,
2378 "",
2379 ];
2380 let (tx, mut rx) = mpsc::unbounded_channel();
2381 let mut decoder = CodexSseDecoder::for_provider("xAI");
2382 let mut text = String::new();
2383 for line in lines {
2384 decoder.push_line(line, &tx, &mut text);
2385 }
2386 decoder.finish();
2387 let failure = decoder.terminal_result().expect_err("must fail");
2388 assert_eq!(failure.code, "responses_capacity");
2389 assert!(failure.is_retryable());
2390 assert_eq!(
2391 failure.message,
2392 "xAI reports the model is at capacity (retries exhausted). Try again in a few minutes or switch models with /model."
2393 );
2394 for banned in ["high demand", "docs.x.ai", "service tier", "Codex"] {
2395 assert!(
2396 !failure.message.contains(banned),
2397 "provider text `{banned}` leaked: {}",
2398 failure.message
2399 );
2400 }
2401 assert!(text.is_empty());
2402 assert!(!decoder.emitted_any_output());
2403 assert!(rx.try_recv().is_err(), "no events for an error-only stream");
2404 }
2405
2406 #[test]
2409 fn flat_code_classifies_capacity_only_for_vetted_codes() {
2410 let capacity = [
2411 r#"data: {"type":"error","code":"rate_limit_exceeded","message":"private"}"#,
2412 "",
2413 ];
2414 let (decoder, _, _) = drive(&capacity);
2415 let failure = decoder.terminal_result().expect_err("must fail");
2416 assert_eq!(failure.code, "responses_capacity");
2417 assert!(failure.is_retryable());
2418
2419 let other = [
2420 r#"data: {"type":"error","code":"invalid_api_key","message":"private"}"#,
2421 "",
2422 ];
2423 let (decoder, _, _) = drive(&other);
2424 let failure = decoder.terminal_result().expect_err("must fail");
2425 assert_eq!(failure.code, "responses_failed");
2426 assert!(!failure.is_retryable());
2427 assert!(!failure.message.contains("private"));
2428 }
2429
2430 #[test]
2431 fn provider_label_is_carried_into_every_terminal_message() {
2432 let (tx, _rx) = mpsc::unbounded_channel();
2433 let mut text = String::new();
2434 let mut decoder = CodexSseDecoder::for_provider("xAI");
2435 decoder.push_line(
2436 r#"data: {"type":"response.completed","response":{"usage":{"input_tokens":1,"output_tokens":0}}}"#,
2437 &tx,
2438 &mut text,
2439 );
2440 decoder.push_line("", &tx, &mut text);
2441 let failure = decoder.terminal_result().expect_err("empty");
2442 assert_eq!(failure.code, "responses_empty");
2443 assert!(failure.is_retryable());
2444 assert_eq!(
2445 failure.message,
2446 "xAI completed without text or tool output. Retry the request."
2447 );
2448
2449 let decoder = CodexSseDecoder::for_provider("xAI");
2450 let failure = decoder.terminal_result().expect_err("missing terminal");
2451 assert_eq!(failure.code, "responses_missing_terminal");
2452 assert!(failure.is_retryable());
2453 assert!(failure.message.starts_with("xAI response stream ended"));
2454
2455 let mut decoder = CodexSseDecoder::for_provider("xAI");
2456 decoder.push_line(
2457 r#"data: {"type":"response.incomplete","response":{"incomplete_details":{"reason":"max_output_tokens"}}}"#,
2458 &tx,
2459 &mut text,
2460 );
2461 decoder.push_line("", &tx, &mut text);
2462 let failure = decoder.terminal_result().expect_err("incomplete");
2463 assert_eq!(failure.code, "responses_incomplete");
2464 assert!(!failure.is_retryable());
2465 assert!(failure.message.starts_with("xAI response was incomplete."));
2466 }
2467
2468 #[test]
2469 fn done_marker_finishes_decoder() {
2470 let lines = [
2471 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_x","name":"bash"}}"#,
2472 "",
2473 r#"data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"{}"}"#,
2474 "",
2475 "data: [DONE]",
2476 "",
2477 ];
2478 let (decoder, _text, _events) = drive(&lines);
2479 assert_eq!(decoder.completed_tools.len(), 1);
2481 assert_eq!(decoder.completed_tools[0].id, "call_x");
2482 assert_eq!(decoder.completed_tools[0].function.arguments, "{}");
2483 }
2484
2485 #[test]
2486 fn finish_is_idempotent_no_double_emit() {
2487 let lines = [
2488 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_y","name":"bash","arguments":"{}"}}"#,
2489 "",
2490 ];
2491 let (mut decoder, _text, _events) = drive(&lines);
2492 assert_eq!(decoder.completed_tools.len(), 1);
2493
2494 decoder.finish();
2496 assert_eq!(
2497 decoder.completed_tools.len(),
2498 1,
2499 "finish() called twice must not double-emit"
2500 );
2501 }
2502
2503 #[test]
2504 fn finish_drains_active_tools_for_state_hygiene() {
2505 let lines = [
2510 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_z","name":"bash"}}"#,
2511 "",
2512 "data: [DONE]",
2513 "",
2514 ];
2515 let (decoder, _text, _events) = drive(&lines);
2516 assert_eq!(decoder.completed_tools.len(), 1);
2517 assert!(
2518 decoder.active_tools.is_empty(),
2519 "active_tools must be drained after finish()"
2520 );
2521 }
2522
2523 #[test]
2524 fn unknown_event_types_are_ignored() {
2525 let lines = [
2526 r#"data: {"type":"response.future_unknown_event","payload":{"x":1}}"#,
2527 "",
2528 r#"data: {"type":"response.output_text.delta","delta":"hi"}"#,
2529 "",
2530 ];
2531 let (_decoder, text_acc, _events) = drive(&lines);
2532 assert_eq!(text_acc, "hi");
2533 }
2534}
2535
2536#[cfg(test)]
2537mod broker_stream_tests {
2538 use super::*;
2544 use crate::auth::{CredentialBroker, LocalBroker};
2545 use std::sync::Arc;
2546
2547 async fn spawn_fake_openai_sse() -> (String, Arc<std::sync::Mutex<String>>) {
2548 use axum::routing::post;
2549 let seen_auth = Arc::new(std::sync::Mutex::new(String::new()));
2550 let seen = seen_auth.clone();
2551 let app = axum::Router::new().route(
2552 "/chat/completions",
2553 post(move |headers: axum::http::HeaderMap| {
2554 let seen = seen.clone();
2555 async move {
2556 *seen.lock().unwrap() = headers
2557 .get("authorization")
2558 .and_then(|v| v.to_str().ok())
2559 .unwrap_or("")
2560 .to_string();
2561 let body = concat!(
2562 "data: {\"choices\":[{\"delta\":{\"content\":\"Hel\"}}]}\n\n",
2563 "data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}\n\n",
2564 "data: [DONE]\n\n",
2565 );
2566 ([("content-type", "text/event-stream")], body)
2567 }
2568 }),
2569 );
2570 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2571 let addr = listener.local_addr().unwrap();
2572 tokio::spawn(async move {
2573 axum::serve(listener, app).await.unwrap();
2574 });
2575 (format!("http://{addr}"), seen_auth)
2576 }
2577
2578 #[tokio::test]
2579 async fn oai_stream_flows_through_broker_and_forwards_deltas() {
2580 let (upstream, seen_auth) = spawn_fake_openai_sse().await;
2581 let broker: Arc<dyn CredentialBroker> = Arc::new(LocalBroker::with_local_base_url(
2582 reqwest::Client::new(),
2583 upstream,
2584 ));
2585 let cfg = ProviderConfig {
2586 base_url: "unused-broker-derives-the-url".to_string(),
2587 model: "test-model".to_string(),
2588 provider: "local".to_string(),
2589 };
2590 let (tx, mut rx) = mpsc::unbounded_channel::<StreamEvent>();
2591 let cancel = tokio_util::sync::CancellationToken::new();
2592
2593 let result = call_oai_stream_inner(
2594 &cfg,
2595 &broker,
2596 &[],
2597 &None,
2598 &[],
2599 &tx,
2600 None,
2601 None,
2602 0,
2603 agent_core::reasoning::ReasoningLevel::Medium,
2604 &cancel,
2605 &crate::runtime::trace::TraceContext::disabled(),
2606 true,
2607 false,
2608 0,
2609 )
2610 .await
2611 .expect("stream must complete");
2612
2613 assert_eq!(result["content"][0]["text"], "Hello");
2615
2616 drop(tx);
2618 let mut streamed = String::new();
2619 while let Ok(ev) = rx.try_recv() {
2620 if let StreamEvent::Llm(crate::runtime::types::LlmEvent::Text(t)) = ev {
2621 streamed.push_str(&t);
2622 }
2623 }
2624 assert_eq!(streamed, "Hello");
2625
2626 assert_eq!(&*seen_auth.lock().unwrap(), "Bearer local");
2628 }
2629
2630 #[tokio::test]
2633 async fn suppressed_sync_route_does_not_enqueue_display_deltas() {
2634 let (upstream, _seen_auth) = spawn_fake_openai_sse().await;
2635 let broker: Arc<dyn CredentialBroker> = Arc::new(LocalBroker::with_local_base_url(
2636 reqwest::Client::new(),
2637 upstream,
2638 ));
2639 let cfg = ProviderConfig {
2640 base_url: "unused-broker-derives-the-url".to_string(),
2641 model: "test-model".to_string(),
2642 provider: "local".to_string(),
2643 };
2644 let (tx, mut rx) = mpsc::unbounded_channel::<StreamEvent>();
2645 let result = call_oai_stream_inner(
2646 &cfg,
2647 &broker,
2648 &[],
2649 &None,
2650 &[],
2651 &tx,
2652 None,
2653 None,
2654 0,
2655 agent_core::reasoning::ReasoningLevel::Medium,
2656 &tokio_util::sync::CancellationToken::new(),
2657 &crate::runtime::trace::TraceContext::disabled(),
2658 true,
2659 true,
2660 0,
2661 )
2662 .await
2663 .expect("stream must complete");
2664 assert_eq!(result["content"][0]["text"], "Hello");
2665 assert!(
2666 rx.try_recv().is_err(),
2667 "sync route must suppress display deltas at production"
2668 );
2669 }
2670
2671 #[tokio::test]
2672 async fn oai_stream_broker_error_fails_closed() {
2673 let broker: Arc<dyn CredentialBroker> = Arc::new(LocalBroker::new(reqwest::Client::new()));
2675 let cfg = ProviderConfig {
2676 base_url: String::new(),
2677 model: "m".to_string(),
2678 provider: "definitely-not-a-provider".to_string(),
2679 };
2680 let (tx, _rx) = mpsc::unbounded_channel::<StreamEvent>();
2681 let cancel = tokio_util::sync::CancellationToken::new();
2682 let err = call_oai_stream_inner(
2683 &cfg,
2684 &broker,
2685 &[],
2686 &None,
2687 &[],
2688 &tx,
2689 None,
2690 None,
2691 0,
2692 agent_core::reasoning::ReasoningLevel::Medium,
2693 &cancel,
2694 &crate::runtime::trace::TraceContext::disabled(),
2695 true,
2696 false,
2697 0,
2698 )
2699 .await
2700 .unwrap_err()
2701 .to_string();
2702 assert!(err.contains("unknown provider"), "got: {err}");
2703 assert!(!err.to_lowercase().contains("bearer"));
2704 }
2705
2706 #[tokio::test]
2711 async fn oai_stream_upstream_error_body_never_surfaces() {
2712 use axum::response::IntoResponse;
2713 const SENTINEL: &str = "PH1-OAI-BROKER-SENTINEL-91d2-RAW";
2714 let app = axum::Router::new().route(
2715 "/chat/completions",
2716 axum::routing::post(move |body: String| async move {
2717 (
2718 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
2719 [("content-type", "application/json")],
2720 format!("{{\"error\":{{\"message\":\"ECHOED:{body}\"}}}}"),
2721 )
2722 .into_response()
2723 }),
2724 );
2725 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2726 let addr = listener.local_addr().unwrap();
2727 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
2728
2729 let broker: Arc<dyn CredentialBroker> = Arc::new(LocalBroker::with_local_base_url(
2730 reqwest::Client::new(),
2731 format!("http://{addr}"),
2732 ));
2733 let cfg = ProviderConfig {
2734 base_url: "unused-broker-derives-the-url".to_string(),
2735 model: "test-model".to_string(),
2736 provider: "local".to_string(),
2737 };
2738 let tools = vec![serde_json::json!({
2739 "name": "ph1_secret_tool_zz",
2740 "description": "internal tool schema",
2741 "input_schema": {"type": "object", "properties": {}}
2742 })];
2743 let msgs: Vec<crate::SharedMessage> = vec![Arc::new(
2744 serde_json::json!({"role": "user", "content": SENTINEL}),
2745 )];
2746 let (tx, _rx) = mpsc::unbounded_channel::<StreamEvent>();
2747 let cancel = tokio_util::sync::CancellationToken::new();
2748
2749 let err = call_oai_stream_inner(
2750 &cfg,
2751 &broker,
2752 &tools,
2753 &Some(format!("system secret {SENTINEL}")),
2754 &msgs,
2755 &tx,
2756 None,
2757 None,
2758 0,
2759 agent_core::reasoning::ReasoningLevel::Medium,
2760 &cancel,
2761 &crate::runtime::trace::TraceContext::disabled(),
2762 true,
2763 false,
2764 0,
2765 )
2766 .await
2767 .expect_err("500 must fail")
2768 .to_string();
2769
2770 assert!(err.contains("500"), "status must survive: {err}");
2771 for banned in ["ECHOED", SENTINEL, "ph1_secret_tool_zz", "system secret"] {
2772 assert!(
2773 !err.contains(banned),
2774 "provider body content `{banned}` leaked into the surfaced error: {err}"
2775 );
2776 }
2777 }
2778}
2779
2780const XAI_PROVIDER_LABEL: &str = "xAI";
2782
2783#[allow(clippy::too_many_arguments)]
2784pub(crate) async fn call_xai_responses_stream_inner(
2785 cfg: &ProviderConfig,
2786 broker: &std::sync::Arc<dyn crate::auth::CredentialBroker>,
2787 tools_schema: &[Value],
2788 system_prompt: &Option<String>,
2789 messages: &[crate::SharedMessage],
2790 tx: &mpsc::UnboundedSender<StreamEvent>,
2791 max_tokens: Option<u32>,
2792 reasoning_level: agent_core::reasoning::ReasoningLevel,
2793 cancel: &tokio_util::sync::CancellationToken,
2794 max_retries: u32,
2795 trace: &crate::runtime::trace::TraceContext,
2796 exact_wire_bytes: bool,
2797) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
2798 let (tools, names) = translate::tools_to_responses(tools_schema);
2799 let input = codex_input_messages(translate::messages_to_oai(messages, system_prompt, &names));
2800 let body = build_xai_body(
2803 &cfg.model,
2804 reasoning_level,
2805 serde_json::to_value(input)?,
2806 tools,
2807 max_tokens,
2808 )
2809 .map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
2810 let (proxy_request, body_bytes) =
2818 crate::auth::ProxyRequest::post_json_exact("xai-auth", "/responses", body, true)?;
2819 let tracer = tr::begin_openai_tracer(
2820 trace,
2821 &cfg.provider,
2822 &cfg.model,
2823 if exact_wire_bytes {
2824 crate::runtime::trace::TransportKind::OpenAiResponses
2825 } else {
2826 crate::runtime::trace::TransportKind::CloudProxy
2827 },
2828 &format!("{}/responses", cfg.base_url.trim_end_matches('/')),
2829 exact_wire_bytes.then_some(body_bytes.as_ref()),
2830 messages,
2831 system_prompt.as_deref(),
2832 tools_schema,
2833 tr::renamed_tool_losses(&names),
2834 )
2835 .await;
2836 if let Some(tracer) = &tracer {
2842 tr::capture_request_content(trace, tracer.request_id(), body_bytes.as_ref(), messages);
2843 }
2844 let mut attempt = tr::StreamAttempt::new(tracer);
2845 let mut stream_retry = 0u32;
2846
2847 loop {
2848 if cancel.is_cancelled() {
2849 return Err("request canceled".into());
2850 }
2851 let stream = crate::runtime::api::await_or_cancel(
2852 cancel,
2853 broker.proxy_stream(proxy_request.clone()),
2854 )
2855 .await
2856 .map_err(|_| "request canceled")?
2857 .map_err(|e| super::net::redact_provider_proxy_error(&e.to_string()));
2860 let mut stream = match stream {
2861 Ok(stream) => {
2862 attempt.mark_headers();
2863 stream
2864 }
2865 Err(msg) => {
2866 let status = tr::broker_error_status(&msg);
2867 let code =
2868 status.map_or_else(|| "broker_error".to_string(), |s| format!("http_{s}"));
2869 attempt.finish_failed(&code, status, None);
2870 return Err(super::net::normalize_broker_http_error(msg).into());
2871 }
2872 };
2873 let _ = tx.send(StreamEvent::Llm(crate::LlmEvent::ResponseStart));
2874 let mut interrupted = false;
2875 let mut text = String::new();
2876 let mut parser = CodexSseDecoder::for_provider(XAI_PROVIDER_LABEL);
2877 let mut buf = bytes::BytesMut::new();
2878 while let Some(chunk) = tokio::select! {
2879 biased;
2880 _ = cancel.cancelled() => {
2881 attempt.finish_canceled(None, parser.trace_usage());
2882 return Err("request canceled".into());
2883 }
2884 c = stream.next() => c,
2885 } {
2886 let chunk = match chunk {
2887 Ok(chunk) => {
2888 attempt.mark_first_byte();
2889 chunk
2890 }
2891 Err(e) => {
2892 if !matches!(e, crate::auth::BrokerError::Transport(_)) {
2893 attempt.finish_failed("broker_error", None, None);
2894 return Err(e.into());
2895 }
2896 interrupted = !parser.terminal_success && parser.terminal_failure.is_none();
2897 break;
2898 }
2899 };
2900 buf.extend_from_slice(&chunk);
2901 while let Some(n) = memchr::memchr(b'\n', &buf) {
2902 let line = buf.split_to(n + 1);
2903 parser.push_line(std::str::from_utf8(&line[..n]).unwrap_or(""), tx, &mut text);
2904 }
2905 if parser.saw_model_event {
2906 attempt.mark_first_model_event();
2907 }
2908 }
2909 if !interrupted && !buf.is_empty() {
2910 parser.push_line(std::str::from_utf8(&buf).unwrap_or(""), tx, &mut text);
2911 }
2912 if interrupted || (!parser.terminal_success && parser.terminal_failure.is_none()) {
2913 if stream_retry >= max_retries {
2914 attempt.finish_failed("stream_error", None, None);
2915 return Err(STREAM_INTERRUPTED.into());
2916 }
2917 stream_retry += 1;
2918 attempt.attempt_failed(
2919 crate::runtime::trace::RetryClass::Network,
2920 retry_delay(stream_retry),
2921 None,
2922 None,
2923 "stream_error",
2924 );
2925 wait_stream_retry(tx, cancel, stream_retry, max_retries, false)
2926 .await
2927 .inspect_err(|_| {
2928 attempt.finish_canceled(None, None);
2929 })?;
2930 attempt.restart_clock();
2931 continue;
2932 }
2933 parser.finish();
2934 if parser.saw_model_event {
2938 attempt.mark_first_model_event();
2939 }
2940 if let Err(failure) = parser.terminal_result() {
2944 let output_already_streamed = !text.is_empty() || parser.emitted_any_output();
2950 if failure.is_retryable() && !output_already_streamed && stream_retry < max_retries {
2951 stream_retry += 1;
2952 let delay = retry_delay(stream_retry);
2953 attempt.attempt_failed(
2954 crate::runtime::trace::RetryClass::Other,
2955 delay,
2956 None,
2957 None,
2958 failure.code,
2959 );
2960 tracing::warn!(
2961 code = failure.code,
2962 "xai stream retry {stream_retry}/{max_retries} after {delay:?}"
2963 );
2964 tokio::select! {
2965 _ = tokio::time::sleep(delay) => {}
2966 _ = cancel.cancelled() => {
2967 attempt.finish_canceled(None, None);
2968 return Err("request canceled".into());
2969 }
2970 }
2971 attempt.restart_clock();
2972 continue;
2973 }
2974 attempt.finish_failed(failure.code, None, None);
2975 return Err(failure.message.into());
2976 }
2977 attempt.finish_success(None, None, None, parser.trace_usage());
2978 let mut content = Vec::new();
2979 if !text.is_empty() {
2980 content.push(json!({"type":"text","text":text}));
2981 }
2982 content.extend(translate::tool_calls_to_content_blocks(
2983 &parser.completed_tools,
2984 &names,
2985 ));
2986 return Ok(json!({"role":"assistant","content":content}));
2987 }
2988}
2989
2990pub(crate) fn build_xai_body(
3001 model: &str,
3002 level: agent_core::reasoning::ReasoningLevel,
3003 input: Value,
3004 tools: Vec<Value>,
3005 max_tokens: Option<u32>,
3006) -> Result<Value, String> {
3007 use agent_core::reasoning::ReasoningLevel;
3008 crate::runtime::openai::catalog::validation::validate_reasoning_mutation(
3009 &format!("xai-auth/{model}"),
3010 level,
3011 )?;
3012 let mut body = serde_json::Map::new();
3013 body.insert("model".into(), json!(model));
3014 body.insert("input".into(), input);
3015 body.insert("stream".into(), json!(true));
3016 if !tools.is_empty() {
3017 body.insert("tools".into(), Value::Array(tools));
3018 }
3019 if let Some(max) = max_tokens {
3020 body.insert("max_output_tokens".into(), json!(max));
3021 }
3022 match level {
3023 ReasoningLevel::Off | ReasoningLevel::Adaptive => {}
3026 l => {
3027 body.insert("reasoning".into(), json!({ "effort": l.as_str() }));
3028 }
3029 }
3030 Ok(Value::Object(body))
3031}
3032
3033#[cfg(test)]
3034mod xai_tests {
3035 use super::*;
3036 use agent_core::reasoning::ReasoningLevel;
3037
3038 #[test]
3039 fn xai_fixture_is_public_responses_shape() {
3040 let fixture = serde_json::json!({"type":"response.output_text.delta","delta":"hello"});
3041 assert_eq!(fixture["type"], "response.output_text.delta");
3042 assert_eq!(fixture["delta"], "hello");
3043 }
3044
3045 fn body_for(model: &str, level: ReasoningLevel) -> Result<Value, String> {
3046 build_xai_body(model, level, json!([]), Vec::new(), Some(1024))
3047 }
3048
3049 #[test]
3052 fn grok45_emits_exact_documented_efforts() {
3053 for (level, effort) in [
3054 (ReasoningLevel::Low, "low"),
3055 (ReasoningLevel::Medium, "medium"),
3056 (ReasoningLevel::High, "high"),
3057 ] {
3058 let body = body_for("grok-4.5", level).expect("supported effort");
3059 assert_eq!(body["reasoning"], json!({"effort": effort}), "{level}");
3060 assert_eq!(body["model"], "grok-4.5");
3061 assert_eq!(body["stream"], json!(true));
3062 assert_eq!(body["max_output_tokens"], json!(1024));
3063 }
3064 }
3065
3066 #[test]
3067 fn grok47_responses_wire_uses_exact_efforts_and_preserves_tool_shape() {
3068 for (level, effort) in [
3069 (ReasoningLevel::Low, "low"),
3070 (ReasoningLevel::Medium, "medium"),
3071 (ReasoningLevel::High, "high"),
3072 (ReasoningLevel::XHigh, "xhigh"),
3073 ] {
3074 let input = json!([{"role": "user", "content": "hello"}]);
3075 let tool = json!({"type": "function", "name": "inspect", "parameters": {"type": "object"}});
3076 let body = build_xai_body("grok-4.7", level, input.clone(), vec![tool.clone()], Some(1024)).unwrap();
3077 assert_eq!(body["model"], "grok-4.7");
3078 assert_eq!(body["reasoning"], json!({"effort": effort}));
3079 assert_eq!(body["input"], input);
3080 assert_eq!(body["tools"], json!([tool]));
3081 assert_eq!(body["stream"], true);
3082 assert_eq!(body["max_output_tokens"], 1024);
3083 assert!(body.get("messages").is_none());
3084 assert!(body.get("reasoning_effort").is_none());
3085 assert!(body.get("include").is_none());
3088 assert!(body.get("store").is_none());
3089 }
3090 assert!(body_for("grok-4.7", ReasoningLevel::Adaptive).unwrap().get("reasoning").is_none());
3091 for level in [ReasoningLevel::Off, ReasoningLevel::Max, ReasoningLevel::Ultra, ReasoningLevel::UltraCode] {
3092 assert!(body_for("grok-4.7", level).is_err(), "{level}");
3093 }
3094 }
3095
3096 #[test]
3097 fn grok46_emits_exact_documented_efforts() {
3098 for (level, effort) in [
3099 (ReasoningLevel::Low, "low"),
3100 (ReasoningLevel::Medium, "medium"),
3101 (ReasoningLevel::High, "high"),
3102 ] {
3103 let body = body_for("grok-4.6", level).expect("supported effort");
3104 assert_eq!(body["reasoning"], json!({"effort": effort}), "{level}");
3105 assert_eq!(body["model"], "grok-4.6");
3106 assert_eq!(body["stream"], json!(true));
3107 assert_eq!(body["max_output_tokens"], json!(1024));
3108 }
3109 }
3110
3111 #[test]
3112 fn adaptive_omits_reasoning_field_provider_default() {
3113 for model in [
3114 "grok-4.5",
3115 "grok-4.5-latest",
3116 "grok-4.6",
3117 "grok-4.3",
3118 "grok-4.20-0309-non-reasoning",
3119 ] {
3120 let body = body_for(model, ReasoningLevel::Adaptive).expect("adaptive is omission");
3121 assert!(body.get("reasoning").is_none(), "{model}");
3122 }
3123 }
3124
3125 #[test]
3126 fn off_is_rejected_pre_network_when_reasoning_cannot_be_disabled() {
3127 for model in [
3128 "grok-4.5",
3129 "grok-4.5-latest",
3130 "grok-4.6",
3131 "grok-4.20-multi-agent-0309",
3132 "grok-4.3",
3133 ] {
3134 let err = body_for(model, ReasoningLevel::Off).unwrap_err();
3135 assert!(err.contains(model), "{model}: {err}");
3136 }
3137 let body = body_for("grok-4.20-0309-non-reasoning", ReasoningLevel::Off).unwrap();
3139 assert!(body.get("reasoning").is_none());
3140 }
3141
3142 #[test]
3143 fn unsupported_named_efforts_are_rejected_pre_network() {
3144 for level in [
3146 ReasoningLevel::XHigh,
3147 ReasoningLevel::Max,
3148 ReasoningLevel::Ultra,
3149 ] {
3150 assert!(body_for("grok-4.5", level).is_err(), "{level}");
3151 assert!(body_for("grok-4.6", level).is_err(), "{level}");
3152 }
3153 assert!(body_for("grok-4.3", ReasoningLevel::Medium).is_err());
3155 assert!(body_for("grok-4.20-0309-non-reasoning", ReasoningLevel::Medium).is_err());
3157 assert!(body_for("grok-9000", ReasoningLevel::Medium).is_err());
3159 }
3160
3161 #[test]
3162 fn multi_agent_xhigh_is_exact_agent_count_control() {
3163 let body = body_for("grok-4.20-multi-agent-0309", ReasoningLevel::XHigh).unwrap();
3164 assert_eq!(body["reasoning"], json!({"effort": "xhigh"}));
3165 }
3166
3167 #[test]
3168 fn responses_wire_shape_tools_flat_and_no_chat_completions_fields() {
3169 let tools = vec![json!({
3170 "type": "function",
3171 "name": "get_weather",
3172 "description": "d",
3173 "parameters": {"type": "object"}
3174 })];
3175 let body =
3176 build_xai_body("grok-4.5", ReasoningLevel::High, json!([]), tools, None).unwrap();
3177 assert_eq!(body["tools"][0]["name"], "get_weather");
3180 assert!(body["tools"][0].get("function").is_none());
3181 assert!(body.get("input").is_some());
3182 assert!(body.get("messages").is_none());
3183 assert!(body.get("max_output_tokens").is_none());
3184 }
3185}
3186
3187#[cfg(test)]
3188mod xai_capacity_retry_tests {
3189 use super::*;
3198 use agent_core::auth::{
3199 AccessToken, BrokerError, ProxyByteStream, ProxyRequest, ProxyResponse,
3200 };
3201 use async_trait::async_trait;
3202 use std::sync::{
3203 atomic::{AtomicUsize, Ordering},
3204 Arc,
3205 };
3206
3207 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";
3210
3211 const XAI_SUCCESS_SSE: &str = concat!(
3212 "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\"}}\n\n",
3213 "data: {\"type\":\"response.output_text.delta\",\"delta\":\"GROK_OK\"}\n\n",
3214 "data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":7,\"output_tokens\":2}}}\n\n",
3215 );
3216
3217 struct ScriptedXaiBroker {
3220 bodies: Vec<&'static str>,
3221 calls: Arc<AtomicUsize>,
3222 seen: Arc<std::sync::Mutex<Vec<ProxyRequest>>>,
3223 }
3224
3225 #[async_trait]
3226 impl crate::auth::CredentialBroker for ScriptedXaiBroker {
3227 async fn access_token(
3228 &self,
3229 _p: agent_core::auth::OAuthProviderId,
3230 ) -> Result<AccessToken, BrokerError> {
3231 Err(BrokerError::Denied(
3232 "xai route must use proxy_stream".into(),
3233 ))
3234 }
3235 async fn proxy(&self, _request: ProxyRequest) -> Result<ProxyResponse, BrokerError> {
3236 Err(BrokerError::Denied("not implemented in stub".into()))
3237 }
3238 async fn proxy_stream(
3239 &self,
3240 request: ProxyRequest,
3241 ) -> Result<ProxyByteStream, BrokerError> {
3242 request.validate()?;
3243 let n = self.calls.fetch_add(1, Ordering::SeqCst);
3244 self.seen.lock().unwrap().push(request);
3245 let body = self.bodies[n.min(self.bodies.len() - 1)];
3246 Ok(Box::pin(futures::stream::iter(vec![Ok(
3247 bytes::Bytes::from(body),
3248 )])))
3249 }
3250 async fn anthropic_usage(&self) -> Result<serde_json::Value, BrokerError> {
3251 Err(BrokerError::Denied("not implemented in stub".into()))
3252 }
3253 async fn capabilities(&self) -> Result<Vec<agent_core::auth::ProviderStatus>, BrokerError> {
3254 Ok(vec![])
3255 }
3256 }
3257
3258 struct Run {
3259 result: Result<Value, Box<dyn std::error::Error + Send + Sync>>,
3260 calls: usize,
3261 seen: Vec<ProxyRequest>,
3262 events: Vec<StreamEvent>,
3263 }
3264
3265 async fn run_xai(bodies: Vec<&'static str>, max_retries: u32) -> Run {
3266 let calls = Arc::new(AtomicUsize::new(0));
3267 let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
3268 let broker: Arc<dyn crate::auth::CredentialBroker> = Arc::new(ScriptedXaiBroker {
3269 bodies,
3270 calls: Arc::clone(&calls),
3271 seen: Arc::clone(&seen),
3272 });
3273 let cfg = ProviderConfig {
3274 base_url: "https://api.x.ai/v1".to_string(),
3275 model: "grok-4.5".to_string(),
3276 provider: "xai-auth".to_string(),
3277 };
3278 let (tx, mut rx) = mpsc::unbounded_channel::<StreamEvent>();
3279 let result = call_xai_responses_stream_inner(
3280 &cfg,
3281 &broker,
3282 &[],
3283 &Some("system".to_string()),
3284 &[],
3285 &tx,
3286 None,
3287 agent_core::reasoning::ReasoningLevel::Adaptive,
3288 &tokio_util::sync::CancellationToken::new(),
3289 max_retries,
3290 &crate::runtime::trace::TraceContext::disabled(),
3291 true,
3292 )
3293 .await;
3294 drop(tx);
3295 let mut events = Vec::new();
3296 while let Ok(event) = rx.try_recv() {
3297 events.push(event);
3298 }
3299 let seen = std::mem::take(&mut *seen.lock().unwrap());
3300 Run {
3301 result,
3302 calls: calls.load(Ordering::SeqCst),
3303 seen,
3304 events,
3305 }
3306 }
3307
3308 fn text_events(events: &[StreamEvent]) -> Vec<String> {
3309 events
3310 .iter()
3311 .filter_map(|e| match e {
3312 StreamEvent::Llm(crate::runtime::types::LlmEvent::Text(t)) => Some(t.clone()),
3313 _ => None,
3314 })
3315 .collect()
3316 }
3317
3318 #[tokio::test(start_paused = true)]
3319 async fn xai_capacity_error_is_retried_with_identical_bytes_then_succeeds() {
3320 let run = run_xai(vec![XAI_CAPACITY_SSE, XAI_SUCCESS_SSE], 3).await;
3321 let value = run.result.expect("second attempt must succeed");
3322 assert_eq!(value["content"][0]["text"], "GROK_OK");
3323 assert_eq!(run.calls, 2, "exactly one retry");
3324 assert_eq!(run.seen.len(), 2);
3325 assert_eq!(
3326 run.seen[0].body_bytes, run.seen[1].body_bytes,
3327 "retry must re-send the identical serialized body"
3328 );
3329 assert_eq!(run.seen[0].body, run.seen[1].body);
3330 assert_eq!(run.seen[0].provider, "xai-auth");
3331 assert_eq!(run.seen[0].path, "/responses");
3332 assert_eq!(text_events(&run.events), vec!["GROK_OK".to_string()]);
3334 }
3335
3336 #[tokio::test(start_paused = true)]
3337 async fn xai_capacity_error_with_zero_retries_surfaces_xai_capacity_message() {
3338 let run = run_xai(vec![XAI_CAPACITY_SSE], 0).await;
3339 let err = run.result.expect_err("no retry budget → terminal");
3340 let msg = err.to_string();
3341 assert_eq!(
3342 msg,
3343 "xAI reports the model is at capacity (retries exhausted). Try again in a few minutes or switch models with /model."
3344 );
3345 assert_eq!(run.calls, 1);
3346 for banned in ["Codex", "high demand", "docs.x.ai", "service tier"] {
3347 assert!(!msg.contains(banned), "`{banned}` leaked: {msg}");
3348 }
3349 assert!(matches!(
3351 super::super::net::provider_error_to_runtime(err),
3352 crate::RuntimeError::ApiStatus(_)
3353 ));
3354 }
3355
3356 #[tokio::test(start_paused = true)]
3357 async fn xai_capacity_error_exhausting_budget_counts_every_attempt() {
3358 let run = run_xai(vec![XAI_CAPACITY_SSE], 2).await;
3359 let err = run.result.expect_err("persistent capacity → terminal");
3360 assert!(err
3361 .to_string()
3362 .starts_with("xAI reports the model is at capacity"));
3363 assert_eq!(run.calls, 3, "initial attempt + 2 retries");
3364 assert!(text_events(&run.events).is_empty());
3365 }
3366
3367 #[tokio::test(start_paused = true)]
3370 async fn xai_does_not_retry_after_partial_text_was_streamed() {
3371 const PARTIAL_THEN_ERROR: &str = concat!(
3372 "data: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\n\n",
3373 "data: {\"type\":\"error\",\"code\":null,\"message\":\"at capacity\"}\n\n",
3374 );
3375 let run = run_xai(vec![PARTIAL_THEN_ERROR, XAI_SUCCESS_SSE], 3).await;
3376 let err = run.result.expect_err("must not retry after partial output");
3377 assert!(err
3378 .to_string()
3379 .starts_with("xAI reports the model is at capacity"));
3380 assert_eq!(run.calls, 1, "no retry once output reached the UI");
3381 assert_eq!(text_events(&run.events), vec!["partial".to_string()]);
3382 }
3383
3384 #[tokio::test(start_paused = true)]
3386 async fn xai_retries_eof_after_tool_preview_with_reset() {
3387 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";
3388 let run = run_xai(vec![TOOL_THEN_EOF, XAI_SUCCESS_SSE], 3).await;
3389 assert_eq!(run.result.unwrap()["content"][0]["text"], "GROK_OK");
3390 assert_eq!(run.calls, 2);
3391 assert_eq!(run.seen[0].body_bytes, run.seen[1].body_bytes);
3392 assert!(run
3393 .events
3394 .iter()
3395 .any(|e| matches!(e, StreamEvent::Llm(crate::LlmEvent::ResponseReset))));
3396 }
3397
3398 #[test]
3399 fn responses_exact_account_errors_survive_decoder_net_and_driver() {
3400 for (code, suffix, expected) in [
3401 ("invalid_api_key", RESPONSES_AUTH_SUFFIX, "auth"),
3402 ("authentication_error", RESPONSES_AUTH_SUFFIX, "auth"),
3403 (
3404 "invalid_authentication_error",
3405 RESPONSES_AUTH_SUFFIX,
3406 "auth",
3407 ),
3408 ("insufficient_quota", RESPONSES_QUOTA_SUFFIX, "quota"),
3409 ("quota_exceeded", RESPONSES_QUOTA_SUFFIX, "quota"),
3410 ("billing_error", RESPONSES_QUOTA_SUFFIX, "quota"),
3411 ] {
3412 for label in ["Codex", "OpenAI", "xAI"] {
3413 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3414 let mut decoder = CodexSseDecoder::for_provider(label);
3415 let frame = format!(
3416 "data: {}\n\n",
3417 json!({"type":"response.failed", "response":{
3418 "error":{"code":code,"message":"SECRET at capacity"}
3419 }})
3420 );
3421 let mut text = String::new();
3422 for line in frame.lines() {
3423 decoder.push_line(line, &tx, &mut text);
3424 }
3425 let failure = decoder.terminal_result().unwrap_err();
3426 assert_eq!(failure.message, format!("{label}{suffix}"));
3427 assert!(!failure.is_retryable());
3428 let runtime = super::super::net::provider_error_to_runtime(failure.message.into());
3429 assert!(matches!(runtime, crate::RuntimeError::ApiStatus(_)));
3430 let typed = crate::runtime::helpers::turn_error_for(&runtime, "test");
3431 assert_eq!(
3432 crate::extensions::session_driver::classify_turn_error(&typed),
3433 (
3434 crate::extensions::session_driver::Outcome::ProviderError,
3435 expected.into()
3436 )
3437 );
3438 assert!(!typed.message.contains("SECRET"));
3439 }
3440 }
3441 for code in [
3442 "permission_denied",
3443 "context_length_exceeded",
3444 "invalid_request_error",
3445 "unknown",
3446 ] {
3447 let message = user_message_for_terminal_failure("Codex", "absent", code, false);
3448 assert_eq!(
3449 crate::extensions::session_driver::classify_error(&message).0,
3450 crate::extensions::session_driver::Outcome::Blocked
3451 );
3452 }
3453 }
3454
3455 #[test]
3456 fn explicit_policy_and_conflicting_error_classes_outrank_capacity_prose() {
3457 for (kind, code) in [
3458 ("absent", "permission_denied"),
3459 ("permission_error", "rate_limit_exceeded"),
3460 ("invalid_request_error", "insufficient_quota"),
3461 ("authentication_error", "quota_exceeded"),
3462 ("server_error", "invalid_api_key"),
3463 ("unknown", "absent"),
3464 ] {
3465 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3466 let mut decoder = CodexSseDecoder::for_provider("Codex");
3467 let frame = format!(
3468 "data: {}",
3469 json!({"type":"response.failed", "response":{
3470 "error":{"type":kind, "code":code, "message":"private policy at capacity"}
3471 }})
3472 );
3473 let mut text = String::new();
3474 decoder.push_line(&frame, &tx, &mut text);
3475 decoder.push_line("", &tx, &mut text);
3476 let failure = decoder.terminal_result().unwrap_err();
3477 assert!(!failure.is_retryable());
3478 assert_eq!(
3479 crate::extensions::session_driver::classify_error(&failure.message).0,
3480 crate::extensions::session_driver::Outcome::Blocked
3481 );
3482 assert!(!failure.message.contains("private"));
3483 }
3484 }
3485
3486 #[tokio::test(start_paused = true)]
3489 async fn xai_non_capacity_error_is_terminal_and_labelled_xai() {
3490 const OTHER_ERROR: &str = "data: {\"type\":\"error\",\"code\":\"invalid_api_key\",\"message\":\"ECHOED:secret prompt\"}\n\n";
3491 let run = run_xai(vec![OTHER_ERROR, XAI_SUCCESS_SSE], 3).await;
3492 let err = run.result.expect_err("must fail");
3493 let msg = err.to_string();
3494 assert_eq!(msg, format!("xAI{RESPONSES_AUTH_SUFFIX}"));
3495 assert!(!msg.contains("ECHOED"));
3496 assert_eq!(run.calls, 1);
3497 }
3498}
3499
3500#[cfg(test)]
3503mod codex_wire_tests {
3504 use crate::runtime::openai::catalog::{
3505 plan_codex_execution, CodexMultiAgentMode, CodexRequestRole,
3506 };
3507 use agent_core::reasoning::ReasoningLevel;
3508
3509 fn plan(
3510 level: ReasoningLevel,
3511 role: CodexRequestRole,
3512 ) -> crate::runtime::openai::catalog::CodexExecutionPlan {
3513 plan_codex_execution("openai-codex/gpt-5.6-sol", level, role, None)
3514 .expect("Sol request plan")
3515 }
3516
3517 #[allow(clippy::too_many_arguments)]
3518 fn body_for_level(
3519 model: &str,
3520 level: ReasoningLevel,
3521 input: serde_json::Value,
3522 instructions: String,
3523 tools: Vec<serde_json::Value>,
3524 temperature: Option<f32>,
3525 max_tokens: Option<u32>,
3526 ) -> serde_json::Value {
3527 let qualified = format!("openai-codex/{model}");
3528 let plan = plan_codex_execution(&qualified, level, CodexRequestRole::Foreground, None)
3529 .expect("request plan");
3530 super::build_codex_body(
3531 model,
3532 &plan,
3533 input,
3534 instructions,
3535 tools,
3536 temperature,
3537 max_tokens,
3538 "synaps-test-cache-key",
3539 )
3540 }
3541
3542 fn codex_effort_for(model_id: &str, level: ReasoningLevel) -> Result<String, String> {
3544 let qualified = format!("openai-codex/{model_id}");
3545 let plan = plan_codex_execution(&qualified, level, CodexRequestRole::Foreground, None)
3546 .map_err(|error| error.to_string())?;
3547 plan.wire_effort
3548 .map(|effort| effort.as_str().to_string())
3549 .ok_or_else(|| "reasoning effort omitted".to_string())
3550 }
3551
3552 #[test]
3553 fn sol_lowers_ultra_to_max_for_requests() {
3554 let effort = codex_effort_for("gpt-5.6-sol", ReasoningLevel::Ultra).unwrap();
3555 assert_eq!(effort, "max");
3556 }
3557
3558 #[test]
3559 fn sol_emits_max_exact() {
3560 let effort = codex_effort_for("gpt-5.6-sol", ReasoningLevel::Max).unwrap();
3561 assert_eq!(effort, "max");
3562 }
3563
3564 #[test]
3565 fn sol_emits_xhigh_exact() {
3566 let effort = codex_effort_for("gpt-5.6-sol", ReasoningLevel::XHigh).unwrap();
3567 assert_eq!(effort, "xhigh");
3568 }
3569
3570 #[test]
3571 fn luna_emits_max_exact() {
3572 let effort = codex_effort_for("gpt-5.6-luna", ReasoningLevel::Max).unwrap();
3573 assert_eq!(effort, "max");
3574 }
3575
3576 #[test]
3577 fn luna_rejects_ultra_before_network() {
3578 let err = codex_effort_for("gpt-5.6-luna", ReasoningLevel::Ultra).unwrap_err();
3580 assert!(err.contains("ultra"), "{err}");
3581 assert!(err.contains("gpt-5.6-luna"), "{err}");
3582 }
3583
3584 #[test]
3585 fn gpt55_emits_xhigh_exact() {
3586 let effort = codex_effort_for("gpt-5.5", ReasoningLevel::XHigh).unwrap();
3587 assert_eq!(effort, "xhigh");
3588 }
3589
3590 #[test]
3591 fn gpt55_rejects_max_before_network() {
3592 assert!(codex_effort_for("gpt-5.5", ReasoningLevel::Max).is_err());
3593 assert!(codex_effort_for("gpt-5.5", ReasoningLevel::Ultra).is_err());
3594 }
3595
3596 #[test]
3597 fn off_and_adaptive_omit_reasoning_field() {
3598 for level in [ReasoningLevel::Off, ReasoningLevel::Adaptive] {
3600 let body = body_for_level(
3601 "gpt-5.6-sol",
3602 level,
3603 serde_json::json!([]),
3604 "sys".to_string(),
3605 vec![],
3606 None,
3607 None,
3608 );
3609 assert!(
3610 body.get("reasoning").is_none(),
3611 "{level:?} must not emit reasoning field, body={body}"
3612 );
3613 }
3614 }
3615
3616 #[test]
3617 fn max_as_str_is_not_xhigh() {
3618 assert_ne!(ReasoningLevel::Max.as_str(), ReasoningLevel::XHigh.as_str());
3619 assert_eq!(ReasoningLevel::Max.as_str(), "max");
3620 }
3621
3622 #[test]
3623 fn ultra_mode_context_is_exact_proactive_developer_item() {
3624 let plan = plan(ReasoningLevel::Ultra, CodexRequestRole::Foreground);
3625 assert_eq!(plan.multi_agent_mode, Some(CodexMultiAgentMode::Proactive));
3626 let item = super::codex_multi_agent_mode_item(&plan).expect("Ultra context item");
3627 assert_eq!(
3628 item.get("type").and_then(serde_json::Value::as_str),
3629 Some("message")
3630 );
3631 assert_eq!(
3632 item.get("role").and_then(serde_json::Value::as_str),
3633 Some("developer")
3634 );
3635 assert_eq!(
3636 item.pointer("/content/0/text")
3637 .and_then(serde_json::Value::as_str),
3638 Some(concat!(
3639 "<multi_agent_mode>",
3640 "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.",
3641 "</multi_agent_mode>"
3642 ))
3643 );
3644 }
3645
3646 #[test]
3647 fn max_and_xhigh_mode_context_is_exact_explicit_only_item() {
3648 for level in [ReasoningLevel::Max, ReasoningLevel::XHigh] {
3649 let plan = plan(level, CodexRequestRole::Foreground);
3650 assert_eq!(
3651 plan.multi_agent_mode,
3652 Some(CodexMultiAgentMode::ExplicitRequestOnly)
3653 );
3654 let item = super::codex_multi_agent_mode_item(&plan).expect("v2 context item");
3655 assert_eq!(
3656 item.pointer("/content/0/text")
3657 .and_then(serde_json::Value::as_str),
3658 Some(concat!(
3659 "<multi_agent_mode>",
3660 "Do not spawn sub-agents unless the user or applicable AGENTS.md/skill instructions explicitly ask for sub-agents, delegation, or parallel agent work.",
3661 "</multi_agent_mode>"
3662 )),
3663 "{level:?}"
3664 );
3665 }
3666 }
3667
3668 #[test]
3669 fn mode_context_is_inserted_once_at_stable_head_position() {
3670 let plan = plan(ReasoningLevel::Ultra, CodexRequestRole::Foreground);
3671 let mut input = vec![
3672 serde_json::json!({"role":"user","content":"first"}),
3673 serde_json::json!({"role":"assistant","content":"answer"}),
3674 serde_json::json!({"role":"user","content":"current"}),
3675 ];
3676 super::insert_codex_multi_agent_mode(&mut input, &plan);
3677 let mode_positions = input
3678 .iter()
3679 .enumerate()
3680 .filter(|(_, item)| {
3681 item.pointer("/content/0/text")
3682 .and_then(serde_json::Value::as_str)
3683 .is_some_and(|text| text.starts_with("<multi_agent_mode>"))
3684 })
3685 .map(|(index, _)| index)
3686 .collect::<Vec<_>>();
3687 assert_eq!(mode_positions, vec![0]);
3688 assert_eq!(
3689 input[3].get("content").and_then(serde_json::Value::as_str),
3690 Some("current")
3691 );
3692 }
3693
3694 #[test]
3700 fn mode_context_placement_preserves_prompt_prefix_across_turns() {
3701 let plan = plan(ReasoningLevel::Ultra, CodexRequestRole::Foreground);
3702 let turn1 = vec![
3703 serde_json::json!({"role":"user","content":"first"}),
3704 serde_json::json!({"role":"assistant","content":"answer"}),
3705 ];
3706 let mut turn2 = turn1.clone();
3707 turn2.push(serde_json::json!({"role":"user","content":"follow-up"}));
3708 let mut input1 = turn1;
3709 let mut input2 = turn2;
3710 super::insert_codex_multi_agent_mode(&mut input1, &plan);
3711 super::insert_codex_multi_agent_mode(&mut input2, &plan);
3712 assert_eq!(
3713 &input2[..input1.len()],
3714 &input1[..],
3715 "turn N's input must be a strict prefix of turn N+1's input"
3716 );
3717 }
3718
3719 #[test]
3720 fn prompt_cache_key_is_stable_for_the_same_conversation_head() {
3721 let first = serde_json::json!({"role":"user","content":"hello"});
3722 let a = super::codex_prompt_cache_key("sys", Some(&first));
3723 let b = super::codex_prompt_cache_key("sys", Some(&first));
3724 assert_eq!(a, b);
3725 assert!(a.starts_with("synaps-"), "{a}");
3726 }
3727
3728 #[test]
3729 fn prompt_cache_key_differs_across_conversations() {
3730 let first = serde_json::json!({"role":"user","content":"hello"});
3731 let other = serde_json::json!({"role":"user","content":"different task"});
3732 assert_ne!(
3733 super::codex_prompt_cache_key("sys", Some(&first)),
3734 super::codex_prompt_cache_key("sys", Some(&other)),
3735 );
3736 assert_ne!(
3737 super::codex_prompt_cache_key("sys", Some(&first)),
3738 super::codex_prompt_cache_key("other-sys", Some(&first)),
3739 );
3740 }
3741
3742 #[test]
3743 fn body_carries_prompt_cache_key() {
3744 let body = body_for_level(
3745 "gpt-5.6-sol",
3746 ReasoningLevel::Medium,
3747 serde_json::json!([]),
3748 "sys".to_string(),
3749 vec![],
3750 None,
3751 None,
3752 );
3753 assert_eq!(
3754 body.get("prompt_cache_key")
3755 .and_then(serde_json::Value::as_str),
3756 Some("synaps-test-cache-key")
3757 );
3758 }
3759
3760 #[test]
3761 fn worker_ultra_has_no_mode_context_item() {
3762 let plan = plan(ReasoningLevel::Ultra, CodexRequestRole::Worker);
3763 assert!(super::codex_multi_agent_mode_item(&plan).is_none());
3764 }
3765
3766 #[test]
3769 fn body_lowers_ultra_reasoning_effort_to_max() {
3770 let body = body_for_level(
3771 "gpt-5.6-sol",
3772 ReasoningLevel::Ultra,
3773 serde_json::json!([]),
3774 "sys".to_string(),
3775 vec![],
3776 None,
3777 None,
3778 );
3779 let effort = body
3780 .get("reasoning")
3781 .and_then(|r| r.get("effort"))
3782 .and_then(serde_json::Value::as_str)
3783 .expect("reasoning.effort must be present for Ultra");
3784 assert_eq!(effort, "max");
3785 }
3786
3787 #[test]
3788 fn body_contains_reasoning_effort_for_max() {
3789 let body = body_for_level(
3790 "gpt-5.6-sol",
3791 ReasoningLevel::Max,
3792 serde_json::json!([]),
3793 "sys".to_string(),
3794 vec![],
3795 None,
3796 None,
3797 );
3798 let effort = body
3799 .get("reasoning")
3800 .and_then(|r| r.get("effort"))
3801 .and_then(serde_json::Value::as_str)
3802 .expect("reasoning.effort must be present for Max");
3803 assert_eq!(effort, "max");
3804 assert_ne!(effort, "xhigh");
3806 }
3807
3808 #[test]
3809 fn body_contains_reasoning_effort_for_xhigh() {
3810 let body = body_for_level(
3811 "gpt-5.6-sol",
3812 ReasoningLevel::XHigh,
3813 serde_json::json!([]),
3814 "sys".to_string(),
3815 vec![],
3816 None,
3817 None,
3818 );
3819 let effort = body
3820 .get("reasoning")
3821 .and_then(|r| r.get("effort"))
3822 .and_then(serde_json::Value::as_str)
3823 .expect("reasoning.effort must be present for XHigh");
3824 assert_eq!(effort, "xhigh");
3825 }
3826
3827 #[test]
3828 fn body_contains_reasoning_effort_for_low_medium_high() {
3829 for (level, expected) in [
3830 (ReasoningLevel::Low, "low"),
3831 (ReasoningLevel::Medium, "medium"),
3832 (ReasoningLevel::High, "high"),
3833 ] {
3834 let body = body_for_level(
3835 "gpt-5.6-sol",
3836 level,
3837 serde_json::json!([]),
3838 "sys".to_string(),
3839 vec![],
3840 None,
3841 None,
3842 );
3843 let effort = body
3844 .get("reasoning")
3845 .and_then(|r| r.get("effort"))
3846 .and_then(serde_json::Value::as_str)
3847 .unwrap_or_else(|| panic!("{level:?} must emit reasoning.effort"));
3848 assert_eq!(effort, expected, "{level:?}");
3849 }
3850 }
3851
3852 #[test]
3853 fn body_always_sets_model_and_stream_and_include() {
3854 let body = body_for_level(
3855 "gpt-5.5",
3856 ReasoningLevel::XHigh,
3857 serde_json::json!([]),
3858 "instructions".to_string(),
3859 vec![],
3860 None,
3861 None,
3862 );
3863 assert_eq!(
3864 body.get("model").and_then(serde_json::Value::as_str),
3865 Some("gpt-5.5")
3866 );
3867 assert_eq!(
3868 body.get("stream").and_then(serde_json::Value::as_bool),
3869 Some(true)
3870 );
3871 let includes = body.get("include").expect("include must be present");
3872 assert!(
3873 includes.as_array().is_some_and(|a| {
3874 a.iter()
3875 .any(|v| v.as_str() == Some("reasoning.encrypted_content"))
3876 }),
3877 "include must contain reasoning.encrypted_content"
3878 );
3879 assert_eq!(
3880 body.get("store").and_then(serde_json::Value::as_bool),
3881 Some(false)
3882 );
3883 }
3884
3885 #[test]
3886 fn body_omits_temperature_and_max_tokens_when_none() {
3887 let body = body_for_level(
3888 "gpt-5.6-sol",
3889 ReasoningLevel::Medium,
3890 serde_json::json!([]),
3891 "sys".to_string(),
3892 vec![],
3893 None,
3894 None,
3895 );
3896 assert!(body.get("temperature").is_none());
3897 assert!(body.get("max_output_tokens").is_none());
3898 }
3899
3900 #[test]
3901 fn body_includes_temperature_and_max_tokens_when_some() {
3902 let body = body_for_level(
3903 "gpt-5.6-sol",
3904 ReasoningLevel::High,
3905 serde_json::json!([]),
3906 "sys".to_string(),
3907 vec![],
3908 Some(0.7),
3909 Some(2048),
3910 );
3911 let temp = body
3912 .get("temperature")
3913 .and_then(serde_json::Value::as_f64)
3914 .unwrap();
3915 assert!((temp - 0.7f64).abs() < 0.01, "temperature={temp}");
3916 assert_eq!(
3917 body.get("max_output_tokens")
3918 .and_then(serde_json::Value::as_u64),
3919 Some(2048)
3920 );
3921 }
3922}
3923
3924#[cfg(test)]
3925mod send_retry_tests {
3926 use super::*;
3936 use agent_core::auth::{
3937 AccessToken, BrokerError, ProxyByteStream, ProxyRequest, ProxyResponse,
3938 };
3939 use agent_core::reasoning::ReasoningLevel;
3940 use async_trait::async_trait;
3941 use axum::{http::StatusCode, response::IntoResponse, routing::post as axum_post, Router};
3942 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
3943 use std::sync::{
3944 atomic::{AtomicUsize, Ordering},
3945 Arc,
3946 };
3947 use tokio::sync::mpsc;
3948
3949 const CODEX_SSE_SUCCESS: &str = concat!(
3951 "data: {\"type\":\"response.output_text.delta\",\"delta\":\"hello\"}\n\n",
3952 "data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n",
3953 "data: [DONE]\n\n",
3954 );
3955
3956 fn fake_codex_token() -> String {
3960 let payload = serde_json::json!({
3961 "https://api.openai.com/auth": { "chatgpt_account_id": "acct_test" }
3962 });
3963 format!("h.{}.s", URL_SAFE_NO_PAD.encode(payload.to_string()))
3964 }
3965
3966 struct TokenOnlyBroker;
3969
3970 #[async_trait]
3971 impl crate::auth::CredentialBroker for TokenOnlyBroker {
3972 async fn access_token(
3973 &self,
3974 _p: agent_core::auth::OAuthProviderId,
3975 ) -> Result<AccessToken, BrokerError> {
3976 Ok(AccessToken {
3977 token: fake_codex_token(),
3978 expires: u64::MAX,
3979 })
3980 }
3981 async fn proxy(&self, _request: ProxyRequest) -> Result<ProxyResponse, BrokerError> {
3982 Err(BrokerError::Denied("not implemented in stub".into()))
3983 }
3984 async fn proxy_stream(
3985 &self,
3986 _request: ProxyRequest,
3987 ) -> Result<ProxyByteStream, BrokerError> {
3988 Err(BrokerError::Denied("not implemented in stub".into()))
3989 }
3990 async fn anthropic_usage(&self) -> Result<serde_json::Value, BrokerError> {
3991 Err(BrokerError::Denied("not implemented in stub".into()))
3992 }
3993 async fn capabilities(&self) -> Result<Vec<agent_core::auth::ProviderStatus>, BrokerError> {
3994 Ok(vec![])
3995 }
3996 }
3997
3998 struct DenyingCountingBroker {
3999 access_calls: Arc<AtomicUsize>,
4000 }
4001
4002 #[async_trait]
4003 impl crate::auth::CredentialBroker for DenyingCountingBroker {
4004 async fn access_token(
4005 &self,
4006 _provider: agent_core::auth::OAuthProviderId,
4007 ) -> Result<AccessToken, BrokerError> {
4008 self.access_calls.fetch_add(1, Ordering::SeqCst);
4009 Err(BrokerError::Denied(
4010 "credential access must not occur".into(),
4011 ))
4012 }
4013
4014 async fn proxy(&self, _request: ProxyRequest) -> Result<ProxyResponse, BrokerError> {
4015 Err(BrokerError::Denied("not implemented in stub".into()))
4016 }
4017
4018 async fn proxy_stream(
4019 &self,
4020 _request: ProxyRequest,
4021 ) -> Result<ProxyByteStream, BrokerError> {
4022 Err(BrokerError::Denied("not implemented in stub".into()))
4023 }
4024
4025 async fn anthropic_usage(&self) -> Result<serde_json::Value, BrokerError> {
4026 Err(BrokerError::Denied("not implemented in stub".into()))
4027 }
4028
4029 async fn capabilities(&self) -> Result<Vec<agent_core::auth::ProviderStatus>, BrokerError> {
4030 Ok(vec![])
4031 }
4032 }
4033
4034 async fn spawn_mock_codex(
4037 fail_count: usize,
4038 fail_status: StatusCode,
4039 ) -> (String, Arc<AtomicUsize>) {
4040 let counter = Arc::new(AtomicUsize::new(0));
4041 let counter_clone = Arc::clone(&counter);
4042 let app = Router::new().route(
4043 "/codex/responses",
4044 axum_post(move || {
4045 let counter = Arc::clone(&counter_clone);
4046 async move {
4047 let n = counter.fetch_add(1, Ordering::SeqCst);
4048 if n < fail_count {
4049 (
4050 fail_status,
4051 [("content-type", "application/json")],
4052 "{\"error\":{\"message\":\"transient upstream sadness\"}}".to_string(),
4053 )
4054 .into_response()
4055 } else {
4056 (
4057 StatusCode::OK,
4058 [("content-type", "text/event-stream")],
4059 CODEX_SSE_SUCCESS.to_string(),
4060 )
4061 .into_response()
4062 }
4063 }
4064 }),
4065 );
4066 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4067 let addr = listener.local_addr().unwrap();
4068 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
4069 (format!("http://{addr}"), counter)
4070 }
4071
4072 async fn spawn_codex_sse(body: &'static str) -> String {
4073 let app = Router::new().route(
4074 "/codex/responses",
4075 axum_post(move || async move {
4076 (
4077 StatusCode::OK,
4078 [("content-type", "text/event-stream")],
4079 body,
4080 )
4081 }),
4082 );
4083 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4084 let addr = listener.local_addr().unwrap();
4085 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
4086 format!("http://{addr}")
4087 }
4088
4089 async fn spawn_empty_then_success_codex() -> (
4090 String,
4091 Arc<AtomicUsize>,
4092 Arc<std::sync::Mutex<Vec<bytes::Bytes>>>,
4093 ) {
4094 let counter = Arc::new(AtomicUsize::new(0));
4095 let bodies = Arc::new(std::sync::Mutex::new(Vec::new()));
4096 let app = Router::new().route(
4097 "/codex/responses",
4098 axum_post({
4099 let counter = Arc::clone(&counter);
4100 let bodies = Arc::clone(&bodies);
4101 move |body: bytes::Bytes| {
4102 let counter = Arc::clone(&counter);
4103 let bodies = Arc::clone(&bodies);
4104 async move {
4105 bodies.lock().unwrap().push(body);
4106 let n = counter.fetch_add(1, Ordering::SeqCst);
4107 let response = if n == 0 {
4108 concat!(
4109 "data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":7,\"output_tokens\":0}}}\n\n",
4110 "data: [DONE]\n\n",
4111 )
4112 } else {
4113 CODEX_SSE_SUCCESS
4114 };
4115 (
4116 StatusCode::OK,
4117 [("content-type", "text/event-stream")],
4118 response,
4119 )
4120 }
4121 }
4122 }),
4123 );
4124 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4125 let addr = listener.local_addr().unwrap();
4126 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
4127 (format!("http://{addr}"), counter, bodies)
4128 }
4129
4130 async fn run_codex(
4131 base_url: &str,
4132 max_retries: u32,
4133 ) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
4134 let cfg = ProviderConfig {
4135 base_url: base_url.to_string(),
4136 model: "gpt-5.6-sol".to_string(),
4138 provider: "openai-codex".to_string(),
4139 };
4140 let client = reqwest::Client::new();
4141 let broker: std::sync::Arc<dyn crate::auth::CredentialBroker> =
4142 std::sync::Arc::new(TokenOnlyBroker);
4143 let (tx, _rx) = mpsc::unbounded_channel();
4144 call_codex_stream_inner(
4145 &cfg,
4146 &client,
4147 &broker,
4148 &[],
4149 &Some("test".to_string()),
4150 &[],
4151 &tx,
4152 None,
4153 None,
4154 agent_core::reasoning::ReasoningLevel::Medium,
4155 crate::runtime::openai::catalog::CodexRequestRole::Foreground,
4156 &tokio_util::sync::CancellationToken::new(),
4157 max_retries,
4158 &crate::runtime::trace::TraceContext::disabled(),
4159 )
4160 .await
4161 }
4162
4163 async fn broken_chunk_server(
4166 scripts: Vec<(&'static str, bool)>,
4167 ) -> (
4168 String,
4169 Arc<std::sync::Mutex<Vec<Vec<u8>>>>,
4170 tokio::task::JoinHandle<()>,
4171 ) {
4172 use tokio::io::{AsyncReadExt, AsyncWriteExt};
4173 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4174 let address = listener.local_addr().unwrap();
4175 let requests = Arc::new(std::sync::Mutex::new(Vec::new()));
4176 let seen = requests.clone();
4177 let task = tokio::spawn(async move {
4178 let mut attempt = 0;
4179 loop {
4180 let (mut socket, _) = listener.accept().await.unwrap();
4181 let mut request = Vec::new();
4182 let end = loop {
4183 let mut chunk = [0; 8192];
4184 let n = socket.read(&mut chunk).await.unwrap();
4185 if n == 0 {
4186 return;
4187 }
4188 request.extend_from_slice(&chunk[..n]);
4189 if let Some(end) = request.windows(4).position(|w| w == b"\r\n\r\n") {
4190 break end + 4;
4191 }
4192 };
4193 let length: usize = String::from_utf8_lossy(&request[..end])
4194 .lines()
4195 .find_map(|line| {
4196 line.to_ascii_lowercase()
4197 .strip_prefix("content-length: ")
4198 .map(str::to_owned)
4199 })
4200 .unwrap()
4201 .parse()
4202 .unwrap();
4203 while request.len() < end + length {
4204 let mut chunk = [0; 8192];
4205 let n = socket.read(&mut chunk).await.unwrap();
4206 if n == 0 {
4207 return;
4208 }
4209 request.extend_from_slice(&chunk[..n]);
4210 }
4211 seen.lock()
4212 .unwrap()
4213 .push(request[end..end + length].to_vec());
4214 let (body, broken) = scripts[attempt.min(scripts.len() - 1)];
4215 attempt += 1;
4216 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();
4217 socket
4218 .write_all(format!("{:x}\r\n{}\r\n", body.len(), body).as_bytes())
4219 .await
4220 .unwrap();
4221 if !broken {
4223 socket.write_all(b"0\r\n\r\n").await.unwrap();
4224 }
4225 socket.shutdown().await.unwrap();
4226 }
4227 });
4228 (format!("http://{address}"), requests, task)
4229 }
4230
4231 async fn codex_with_events(
4232 base: &str,
4233 retries: u32,
4234 cancel: &tokio_util::sync::CancellationToken,
4235 tx: &mpsc::UnboundedSender<StreamEvent>,
4236 ) -> Result<Value, super::super::net::BoxedProviderError> {
4237 let cfg = ProviderConfig {
4238 base_url: base.into(),
4239 model: "gpt-5.6-sol".into(),
4240 provider: "openai-codex".into(),
4241 };
4242 let broker: Arc<dyn crate::auth::CredentialBroker> = Arc::new(TokenOnlyBroker);
4243 call_codex_stream_inner(
4244 &cfg,
4245 &reqwest::Client::new(),
4246 &broker,
4247 &[],
4248 &Some("test".into()),
4249 &[],
4250 tx,
4251 None,
4252 None,
4253 agent_core::reasoning::ReasoningLevel::Medium,
4254 crate::runtime::openai::catalog::CodexRequestRole::Foreground,
4255 cancel,
4256 retries,
4257 &crate::runtime::trace::TraceContext::disabled(),
4258 )
4259 .await
4260 }
4261
4262 const PARTIAL_TOOL: &str = concat!(
4263 "data: {\"type\":\"response.output_text.delta\",\"delta\":\"discard me\"}\n\n",
4264 "data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"function_call\",\"call_id\":\"old_call\",\"name\":\"write\",\"arguments\":\"\"}}\n\n",
4265 "data: {\"type\":\"response.function_call_arguments.delta\",\"output_index\":0,\"delta\":\"{\\\"path\\\":\"}\n\n",
4266 );
4267 const COMPLETE_TOOL: &str = concat!(
4268 "data: {\"type\":\"response.output_text.delta\",\"delta\":\"accepted\"}\n\n",
4269 "data: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"type\":\"function_call\",\"call_id\":\"new_call\",\"name\":\"write\",\"arguments\":\"{\\\"path\\\":\\\"ok\\\"}\"}}\n\n",
4270 "data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":3,\"output_tokens\":2}}}\n\n",
4271 );
4272
4273 #[tokio::test]
4274 async fn interrupted_partial_tool_retries_identical_bytes_and_commits_only_success() {
4275 use crate::LlmEvent;
4276 let (url, requests, server) =
4277 broken_chunk_server(vec![(PARTIAL_TOOL, true), (COMPLETE_TOOL, false)]).await;
4278 let (tx, mut rx) = mpsc::unbounded_channel();
4279 let result = codex_with_events(&url, 1, &tokio_util::sync::CancellationToken::new(), &tx)
4280 .await
4281 .unwrap();
4282 server.abort();
4283 let requests = requests.lock().unwrap();
4284 assert_eq!(requests.len(), 2);
4285 assert_eq!(requests[0], requests[1]);
4286 let tools: Vec<_> = result["content"]
4287 .as_array()
4288 .unwrap()
4289 .iter()
4290 .filter(|v| v["type"] == "tool_use")
4291 .collect();
4292 assert_eq!(tools.len(), 1);
4293 assert_eq!(tools[0]["id"], "new_call");
4294 assert_eq!(result["content"][0]["text"], "accepted");
4295 let mut preview = String::from("prior completed round");
4296 let mut baseline = 0;
4297 let mut resets = 0;
4298 let mut input_tokens = 0;
4299 while let Ok(event) = rx.try_recv() {
4300 match event {
4301 StreamEvent::Llm(LlmEvent::ResponseStart) => baseline = preview.len(),
4302 StreamEvent::Llm(LlmEvent::ResponseReset) => {
4303 preview.truncate(baseline);
4304 resets += 1;
4305 }
4306 StreamEvent::Llm(LlmEvent::Text(t)) => preview.push_str(&t),
4307 StreamEvent::Session(crate::SessionEvent::Usage {
4308 input_tokens: n, ..
4309 }) => input_tokens += n,
4310 _ => {}
4311 }
4312 }
4313 assert_eq!(resets, 1);
4314 assert_eq!(preview, "prior completed roundaccepted");
4315 assert_eq!(input_tokens, 3);
4316 }
4317
4318 #[tokio::test]
4319 async fn completed_response_followed_by_chunk_error_is_not_replayed() {
4320 let (url, requests, server) = broken_chunk_server(vec![(CODEX_SSE_SUCCESS, true)]).await;
4321 assert!(run_codex(&url, 2).await.is_ok());
4322 server.abort();
4323 assert_eq!(requests.lock().unwrap().len(), 1);
4324 }
4325
4326 #[tokio::test]
4327 async fn clean_truncated_eof_retries_but_never_accepts_partial_tools() {
4328 let (url, requests, server) = broken_chunk_server(vec![(PARTIAL_TOOL, false)]).await;
4329 assert!(run_codex(&url, 1).await.is_err());
4330 server.abort();
4331 assert_eq!(requests.lock().unwrap().len(), 2);
4332 }
4333
4334 #[tokio::test]
4335 async fn broken_chunk_retry_budget_is_finite() {
4336 let (url, requests, server) = broken_chunk_server(vec![(PARTIAL_TOOL, true)]).await;
4337 assert!(run_codex(&url, 1).await.is_err());
4338 server.abort();
4339 assert_eq!(requests.lock().unwrap().len(), 2);
4340 }
4341
4342 #[tokio::test]
4343 async fn cancellation_during_stream_retry_prevents_second_dispatch() {
4344 let (url, requests, server) = broken_chunk_server(vec![(PARTIAL_TOOL, true)]).await;
4345 let (tx, mut rx) = mpsc::unbounded_channel();
4346 let cancel = tokio_util::sync::CancellationToken::new();
4347 let waiter = cancel.clone();
4348 let task = tokio::spawn(async move {
4349 while let Some(event) = rx.recv().await {
4350 if matches!(event, StreamEvent::Session(crate::SessionEvent::Notice(_))) {
4351 waiter.cancel();
4352 break;
4353 }
4354 }
4355 });
4356 let error = codex_with_events(&url, 10, &cancel, &tx).await.unwrap_err();
4357 task.await.unwrap();
4358 server.abort();
4359 assert_eq!(error.to_string(), "request canceled");
4360 assert_eq!(requests.lock().unwrap().len(), 1);
4361 }
4362
4363 #[tokio::test]
4364 async fn direct_ultra_tool_guard_precedes_broker_access() {
4365 let access_calls = Arc::new(AtomicUsize::new(0));
4366 let broker: Arc<dyn crate::auth::CredentialBroker> = Arc::new(DenyingCountingBroker {
4367 access_calls: Arc::clone(&access_calls),
4368 });
4369 let cfg = ProviderConfig {
4370 base_url: "http://127.0.0.1:1".to_string(),
4371 model: "gpt-5.6-sol".to_string(),
4372 provider: "openai-codex".to_string(),
4373 };
4374 let (tx, _rx) = mpsc::unbounded_channel();
4375
4376 let error = call_codex_stream_inner(
4377 &cfg,
4378 &reqwest::Client::new(),
4379 &broker,
4380 &[],
4381 &Some("test".to_string()),
4382 &[],
4383 &tx,
4384 None,
4385 None,
4386 ReasoningLevel::Ultra,
4387 crate::runtime::openai::catalog::CodexRequestRole::Foreground,
4388 &tokio_util::sync::CancellationToken::new(),
4389 0,
4390 &crate::runtime::trace::TraceContext::disabled(),
4391 )
4392 .await
4393 .expect_err("foreground Ultra without delegation tools must fail closed");
4394
4395 assert!(error.to_string().contains("subagent_start"), "{error}");
4396 assert_eq!(
4397 access_calls.load(Ordering::SeqCst),
4398 0,
4399 "preflight denial must happen before broker credential access"
4400 );
4401 }
4402
4403 #[tokio::test]
4404 async fn direct_codex_path_rejects_provider_identity_before_broker_access() {
4405 let access_calls = Arc::new(AtomicUsize::new(0));
4406 let broker: Arc<dyn crate::auth::CredentialBroker> = Arc::new(DenyingCountingBroker {
4407 access_calls: Arc::clone(&access_calls),
4408 });
4409 let cfg = ProviderConfig {
4410 base_url: "http://127.0.0.1:1".to_string(),
4411 model: "gpt-5.6-sol".to_string(),
4412 provider: "openrouter".to_string(),
4413 };
4414 let (tx, _rx) = mpsc::unbounded_channel();
4415
4416 let error = call_codex_stream_inner(
4417 &cfg,
4418 &reqwest::Client::new(),
4419 &broker,
4420 &[],
4421 &Some("test".to_string()),
4422 &[],
4423 &tx,
4424 None,
4425 None,
4426 ReasoningLevel::Medium,
4427 crate::runtime::openai::catalog::CodexRequestRole::Foreground,
4428 &tokio_util::sync::CancellationToken::new(),
4429 0,
4430 &crate::runtime::trace::TraceContext::disabled(),
4431 )
4432 .await
4433 .expect_err("the Codex seam must validate provider-qualified identity");
4434
4435 assert!(error.to_string().contains("openrouter"), "{error}");
4436 assert_eq!(
4437 access_calls.load(Ordering::SeqCst),
4438 0,
4439 "provider identity denial must precede broker credential access"
4440 );
4441 }
4442
4443 #[tokio::test]
4449 async fn codex_response_failed_event_is_a_terminal_error_not_empty_success() {
4450 let base_url = spawn_codex_sse(concat!(
4451 "data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"type\":\"server_error\",\"message\":\"ECHOED:secret prompt\"}}}\n\n",
4452 "data: [DONE]\n\n",
4453 ))
4454 .await;
4455 let err = run_codex(&base_url, 0)
4456 .await
4457 .expect_err("response.failed must fail the request");
4458 let msg = err.to_string();
4459 assert!(
4460 msg == format!("Codex{RESPONSES_CAPACITY_SUFFIX}"),
4461 "static transient failure message expected: {msg}"
4462 );
4463 assert!(
4464 !msg.contains("ECHOED") && !msg.contains("secret prompt"),
4465 "provider error body must never surface: {msg}"
4466 );
4467 }
4468
4469 #[tokio::test]
4472 async fn codex_response_incomplete_event_is_a_terminal_error() {
4473 let base_url = spawn_codex_sse(concat!(
4474 "data: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\n\n",
4475 "data: {\"type\":\"response.incomplete\",\"response\":{\"incomplete_details\":{\"reason\":\"max_output_tokens\"}}}\n\n",
4476 "data: [DONE]\n\n",
4477 ))
4478 .await;
4479 let err = run_codex(&base_url, 0)
4480 .await
4481 .expect_err("response.incomplete must fail the request");
4482 assert!(
4483 err.to_string()
4484 .starts_with("Codex response was incomplete."),
4485 "got: {err}"
4486 );
4487 }
4488
4489 #[tokio::test]
4492 async fn codex_eof_without_terminal_event_fails_closed() {
4493 let base_url = spawn_codex_sse(
4494 "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\"}}\n\n",
4495 )
4496 .await;
4497 let err = run_codex(&base_url, 0)
4498 .await
4499 .expect_err("EOF without terminal event must fail");
4500 assert!(
4501 err.to_string()
4502 .starts_with("Codex response stream ended without a terminal event."),
4503 "got: {err}"
4504 );
4505 }
4506
4507 #[tokio::test]
4508 async fn codex_completed_without_output_retries_identical_bytes_then_succeeds() {
4509 let (base_url, counter, bodies) = spawn_empty_then_success_codex().await;
4510 let result = run_codex(&base_url, 1)
4511 .await
4512 .expect("one empty-success stream with retries available must not abort the turn");
4513
4514 assert_eq!(counter.load(Ordering::SeqCst), 2, "expected one retry");
4515 let received = bodies.lock().unwrap();
4516 assert_eq!(received.len(), 2, "both attempts must reach the endpoint");
4517 assert_eq!(
4518 received[0], received[1],
4519 "the logical request must resend byte-identical content"
4520 );
4521 assert_eq!(result["content"][0]["text"], "hello");
4522 }
4523
4524 #[tokio::test]
4525 async fn codex_retries_transient_500_then_succeeds() {
4526 let (base_url, counter) = spawn_mock_codex(1, StatusCode::INTERNAL_SERVER_ERROR).await;
4527 let result = run_codex(&base_url, 2)
4528 .await
4529 .expect("one transient 500 with retries available must not abort the turn");
4530 assert_eq!(
4531 counter.load(Ordering::SeqCst),
4532 2,
4533 "expected exactly one retry"
4534 );
4535 let text = result["content"][0]["text"].as_str().unwrap_or_default();
4536 assert_eq!(text, "hello");
4537 }
4538
4539 #[tokio::test]
4540 async fn codex_response_failed_surfaces_typed_provider_error_not_empty_response() {
4541 const BODY: &str = concat!(
4542 "data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"type\":\"server_error\",\"message\":\"ECHOED:private prompt\"}}}\n\n",
4543 "data: [DONE]\n\n",
4544 );
4545 let base_url = spawn_codex_sse(BODY).await;
4546 let err = run_codex(&base_url, 0).await.expect_err("must fail");
4547 let msg = err.to_string();
4548 assert_eq!(msg, format!("Codex{RESPONSES_CAPACITY_SUFFIX}"));
4549 assert!(!msg.contains("empty response"), "{msg}");
4550 assert!(!msg.contains("ECHOED"), "provider text leaked: {msg}");
4551 assert!(
4552 !msg.contains("private prompt"),
4553 "provider text leaked: {msg}"
4554 );
4555 }
4556
4557 #[tokio::test]
4558 async fn codex_response_incomplete_surfaces_typed_provider_error_not_empty_response() {
4559 const BODY: &str = concat!(
4560 "data: {\"type\":\"response.incomplete\",\"response\":{\"incomplete_details\":{\"reason\":\"max_output_tokens\"}}}\n\n",
4561 "data: [DONE]\n\n",
4562 );
4563 let base_url = spawn_codex_sse(BODY).await;
4564 let err = run_codex(&base_url, 0).await.expect_err("must fail");
4565 let msg = err.to_string();
4566 assert!(msg.starts_with("Codex response was incomplete."), "{msg}");
4567 assert!(!msg.contains("empty response"), "{msg}");
4568 assert!(
4569 !msg.contains("max_output_tokens"),
4570 "provider text leaked: {msg}"
4571 );
4572 }
4573
4574 #[tokio::test]
4575 async fn codex_clean_eof_without_terminal_fails_closed() {
4576 let base_url = spawn_codex_sse(
4577 "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_123\"}}\n\n",
4578 )
4579 .await;
4580 let err = run_codex(&base_url, 0).await.expect_err("must fail");
4581 assert!(
4582 err.to_string()
4583 .starts_with("Codex response stream ended without a terminal event."),
4584 "{err}"
4585 );
4586 }
4587
4588 #[tokio::test]
4589 async fn codex_retries_429_then_succeeds() {
4590 let (base_url, counter) = spawn_mock_codex(1, StatusCode::TOO_MANY_REQUESTS).await;
4591 run_codex(&base_url, 2)
4592 .await
4593 .expect("429 is transient — must retry");
4594 assert_eq!(counter.load(Ordering::SeqCst), 2);
4595 }
4596
4597 #[tokio::test]
4598 async fn codex_zero_retries_fails_fast_with_status_error() {
4599 let (base_url, counter) =
4600 spawn_mock_codex(usize::MAX, StatusCode::INTERNAL_SERVER_ERROR).await;
4601 let err = run_codex(&base_url, 0).await.expect_err("must fail");
4602 assert_eq!(counter.load(Ordering::SeqCst), 1, "no retries budgeted");
4603 let msg = err.to_string();
4604 assert!(
4605 msg.starts_with("codex request failed: 500"),
4606 "status must survive for classification: {msg}"
4607 );
4608 }
4609
4610 #[tokio::test]
4611 async fn codex_does_not_retry_client_errors() {
4612 let (base_url, counter) = spawn_mock_codex(usize::MAX, StatusCode::BAD_REQUEST).await;
4613 let err = run_codex(&base_url, 3).await.expect_err("must fail");
4614 assert_eq!(
4615 counter.load(Ordering::SeqCst),
4616 1,
4617 "400 is deterministic — retrying it never helps"
4618 );
4619 assert!(err.to_string().starts_with("codex request failed: 400"));
4620 }
4621
4622 #[tokio::test]
4623 async fn codex_exhausted_retries_reports_last_status() {
4624 let (base_url, counter) =
4625 spawn_mock_codex(usize::MAX, StatusCode::SERVICE_UNAVAILABLE).await;
4626 let err = run_codex(&base_url, 1).await.expect_err("must fail");
4627 assert_eq!(
4628 counter.load(Ordering::SeqCst),
4629 2,
4630 "initial attempt + 1 retry"
4631 );
4632 assert!(err.to_string().starts_with("codex request failed: 503"));
4633 }
4634
4635 #[test]
4639 fn codex_retry_budget_lifts_configured_default_to_persistent_floor() {
4640 assert_eq!(codex_retry_budget(3), CODEX_PERSISTENT_RETRIES);
4641 assert_eq!(codex_retry_budget(0), CODEX_PERSISTENT_RETRIES);
4642 assert_eq!(codex_retry_budget(10), 10);
4643 }
4644
4645 #[test]
4648 fn codex_retry_budget_honors_larger_user_configuration() {
4649 assert_eq!(codex_retry_budget(15), 15);
4650 }
4651
4652 #[test]
4655 fn retry_delay_is_exponential_with_a_capped_tail() {
4656 assert_eq!(retry_delay(1).as_secs(), 1);
4657 assert_eq!(retry_delay(2).as_secs(), 2);
4658 assert_eq!(retry_delay(4).as_secs(), 8);
4659 assert_eq!(retry_delay(7).as_secs(), 64);
4660 assert_eq!(retry_delay(10).as_secs(), 64);
4661 }
4662
4663 const ECHO_SENTINEL: &str = "PH1-OAI-SENTINEL-2b8c4d-RAW-CONTENT";
4671
4672 struct CaptureWriter(Arc<std::sync::Mutex<Vec<u8>>>);
4674
4675 impl std::io::Write for CaptureWriter {
4676 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
4677 self.0.lock().unwrap().extend_from_slice(buf);
4678 Ok(buf.len())
4679 }
4680 fn flush(&mut self) -> std::io::Result<()> {
4681 Ok(())
4682 }
4683 }
4684
4685 fn capture_subscriber(
4686 buf: &Arc<std::sync::Mutex<Vec<u8>>>,
4687 ) -> impl tracing::Subscriber + Send + Sync {
4688 let sink = buf.clone();
4689 tracing_subscriber::fmt()
4690 .with_max_level(tracing::Level::TRACE)
4691 .with_writer(move || CaptureWriter(sink.clone()))
4692 .with_ansi(false)
4693 .finish()
4694 }
4695
4696 async fn spawn_hostile_echo_provider(status: StatusCode) -> String {
4700 let app = Router::new().fallback(move |body: String| async move {
4701 let envelope = serde_json::json!({
4702 "error": { "message": format!("ECHOED:{body}") }
4703 });
4704 (
4705 status,
4706 [("content-type", "application/json")],
4707 envelope.to_string(),
4708 )
4709 .into_response()
4710 });
4711 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4712 let addr = listener.local_addr().unwrap();
4713 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
4714 format!("http://{addr}")
4715 }
4716
4717 fn sentinel_request_body() -> Value {
4721 json!({
4722 "model": "gpt-test",
4723 "messages": [
4724 {"role": "system", "content": format!("system secret {ECHO_SENTINEL}")},
4725 {"role": "user", "content": ECHO_SENTINEL},
4726 ],
4727 "tools": [{
4728 "type": "function",
4729 "function": {
4730 "name": "ph1_secret_tool_zz",
4731 "description": "internal tool schema",
4732 "parameters": {"type": "object", "properties": {}}
4733 }
4734 }],
4735 })
4736 }
4737
4738 const BANNED_MARKERS: &[&str] = &[
4740 "ECHOED",
4741 ECHO_SENTINEL,
4742 "ph1_secret_tool_zz",
4743 "\"messages\"",
4744 "system secret",
4745 ];
4746
4747 fn assert_no_banned(haystack: &str, context: &str) {
4748 for banned in BANNED_MARKERS {
4749 assert!(
4750 !haystack.contains(banned),
4751 "provider body content `{banned}` leaked into {context}: {haystack}"
4752 );
4753 }
4754 }
4755
4756 #[tokio::test]
4757 async fn send_with_retries_non_retryable_error_never_surfaces_provider_body() {
4758 let url = spawn_hostile_echo_provider(StatusCode::BAD_REQUEST).await;
4759 let client = reqwest::Client::new();
4760 let body = sentinel_request_body();
4761
4762 let buf = Arc::new(std::sync::Mutex::new(Vec::new()));
4763 let _guard = tracing::subscriber::set_default(capture_subscriber(&buf));
4764
4765 let err = send_with_retries(
4766 "codex",
4767 &url,
4768 || client.post(&url).json(&body),
4769 &tokio_util::sync::CancellationToken::new(),
4770 3,
4771 &mut 0,
4772 &mut tr::StreamAttempt::new(None),
4773 )
4774 .await
4775 .expect_err("400 must fail fast");
4776
4777 let msg = err.to_string();
4778 assert!(
4779 msg.starts_with("codex request failed: 400"),
4780 "status + label must survive for classification/usability: {msg}"
4781 );
4782 assert_no_banned(&msg, "the surfaced error");
4783 let logs = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
4784 assert_no_banned(&logs, "tracing output");
4785 }
4786
4787 #[tokio::test(start_paused = true)]
4788 async fn send_with_retries_retry_logs_never_contain_provider_body() {
4789 let url = spawn_hostile_echo_provider(StatusCode::SERVICE_UNAVAILABLE).await;
4790 let client = reqwest::Client::new();
4791 let body = sentinel_request_body();
4792
4793 let buf = Arc::new(std::sync::Mutex::new(Vec::new()));
4794 let _guard = tracing::subscriber::set_default(capture_subscriber(&buf));
4795
4796 let err = send_with_retries(
4797 "codex",
4798 &url,
4799 || client.post(&url).json(&body),
4800 &tokio_util::sync::CancellationToken::new(),
4801 1,
4802 &mut 0,
4803 &mut tr::StreamAttempt::new(None),
4804 )
4805 .await
4806 .expect_err("persistent 503 must exhaust the budget");
4807
4808 let msg = err.to_string();
4809 assert!(
4810 msg.starts_with("codex request failed: 503"),
4811 "status + label must survive after exhaustion: {msg}"
4812 );
4813 assert_no_banned(&msg, "the surfaced error");
4814
4815 let logs = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
4816 assert!(
4817 logs.contains("retry 1/1"),
4818 "retry accounting must stay observable: {logs}"
4819 );
4820 assert!(
4821 logs.contains("503"),
4822 "retry log must keep the status for diagnosis: {logs}"
4823 );
4824 assert_no_banned(&logs, "retry tracing output");
4825 }
4826}
4827
4828#[cfg(test)]
4829mod interrupted_broker_tests {
4830 use super::*;
4831 use crate::auth::{
4832 AccessToken, BrokerError, CredentialBroker, ProxyByteStream, ProxyRequest, ProxyResponse,
4833 };
4834 use std::sync::{Arc, Mutex};
4835 use tokio_util::sync::CancellationToken;
4836
4837 struct Broker {
4838 bodies: Vec<(&'static str, Option<BrokerError>)>,
4839 seen: Mutex<Vec<ProxyRequest>>,
4840 }
4841 #[async_trait::async_trait]
4842 impl CredentialBroker for Broker {
4843 async fn access_token(
4844 &self,
4845 _: crate::auth::OAuthProviderId,
4846 ) -> Result<AccessToken, BrokerError> {
4847 unreachable!()
4848 }
4849 async fn proxy(&self, _: ProxyRequest) -> Result<ProxyResponse, BrokerError> {
4850 unreachable!()
4851 }
4852 async fn proxy_stream(
4853 &self,
4854 request: ProxyRequest,
4855 ) -> Result<ProxyByteStream, BrokerError> {
4856 let mut seen = self.seen.lock().unwrap();
4857 let (body, error) = &self.bodies[seen.len().min(self.bodies.len() - 1)];
4858 seen.push(request);
4859 let mut chunks = vec![Ok(bytes::Bytes::from_static(body.as_bytes()))];
4860 if let Some(error) = error {
4861 chunks.push(Err(error.clone()));
4862 }
4863 Ok(Box::pin(futures::stream::iter(chunks)))
4864 }
4865 async fn anthropic_usage(&self) -> Result<Value, BrokerError> {
4866 unreachable!()
4867 }
4868 async fn capabilities(&self) -> Result<Vec<agent_core::auth::ProviderStatus>, BrokerError> {
4869 Ok(vec![])
4870 }
4871 }
4872 const CHAT_PARTIAL: &str = concat!(
4873 "data: {\"choices\":[{\"delta\":{\"content\":\"discard\"}}]}\n\n",
4874 "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":7,\"completion_tokens\":1}}\n\n"
4875 );
4876 const CHAT_OK: &str =
4877 "data: {\"choices\":[{\"delta\":{\"content\":\"accepted\"}}]}\n\ndata: [DONE]\n\n";
4878 const XAI_PARTIAL: &str =
4879 "data: {\"type\":\"response.output_text.delta\",\"delta\":\"discard\"}\n\n";
4880 const XAI_OK: &str = "data: {\"type\":\"response.output_text.delta\",\"delta\":\"accepted\"}\n\ndata: {\"type\":\"response.completed\"}\n\n";
4881
4882 async fn drive(
4883 broker: Arc<Broker>,
4884 xai: bool,
4885 retries: u32,
4886 suppressed: bool,
4887 cancel: &CancellationToken,
4888 ) -> (
4889 Result<Value, super::super::net::BoxedProviderError>,
4890 Vec<StreamEvent>,
4891 ) {
4892 let cfg = ProviderConfig {
4893 base_url: "http://unused.invalid".into(),
4894 model: if xai { "grok-4.5" } else { "test" }.into(),
4895 provider: if xai { "xai-auth" } else { "local" }.into(),
4896 };
4897 let broker: Arc<dyn CredentialBroker> = broker;
4898 let (tx, mut rx) = mpsc::unbounded_channel();
4899 let trace = crate::runtime::trace::TraceContext::disabled();
4900 let result = if xai {
4901 call_xai_responses_stream_inner(
4902 &cfg,
4903 &broker,
4904 &[],
4905 &None,
4906 &[],
4907 &tx,
4908 None,
4909 agent_core::reasoning::ReasoningLevel::Adaptive,
4910 cancel,
4911 retries,
4912 &trace,
4913 true,
4914 )
4915 .await
4916 } else {
4917 call_oai_stream_inner(
4918 &cfg,
4919 &broker,
4920 &[],
4921 &None,
4922 &[],
4923 &tx,
4924 None,
4925 None,
4926 0,
4927 agent_core::reasoning::ReasoningLevel::Medium,
4928 cancel,
4929 &trace,
4930 true,
4931 suppressed,
4932 retries,
4933 )
4934 .await
4935 };
4936 let mut events = vec![];
4937 while let Ok(event) = rx.try_recv() {
4938 events.push(event);
4939 }
4940 (result, events)
4941 }
4942
4943 #[tokio::test(start_paused = true)]
4944 async fn broker_transport_recovery_preserves_usage_and_identical_bytes() {
4945 for xai in [false, true] {
4946 let broker = Arc::new(Broker {
4947 bodies: vec![
4948 (
4949 if xai { XAI_PARTIAL } else { CHAT_PARTIAL },
4950 Some(BrokerError::Transport(
4951 "unexpected EOF during chunk size line".into(),
4952 )),
4953 ),
4954 (if xai { XAI_OK } else { CHAT_OK }, None),
4955 ],
4956 seen: Mutex::new(vec![]),
4957 });
4958 let (result, events) =
4959 drive(broker.clone(), xai, 1, false, &CancellationToken::new()).await;
4960 assert_eq!(result.unwrap()["content"][0]["text"], "accepted");
4961 let seen = broker.seen.lock().unwrap();
4962 assert_eq!(seen.len(), 2);
4963 assert_eq!(seen[0].body_bytes, seen[1].body_bytes);
4964 assert_eq!(
4965 events
4966 .iter()
4967 .filter(|e| matches!(e, StreamEvent::Llm(crate::LlmEvent::ResponseReset)))
4968 .count(),
4969 1
4970 );
4971 if !xai {
4972 assert_eq!(
4973 events
4974 .iter()
4975 .filter_map(|e| match e {
4976 StreamEvent::Session(crate::SessionEvent::Usage {
4977 input_tokens,
4978 ..
4979 }) => Some(*input_tokens),
4980 _ => None,
4981 })
4982 .sum::<u64>(),
4983 7
4984 );
4985 }
4986 }
4987 }
4988 #[tokio::test(start_paused = true)]
4989 async fn broker_nontransport_denial_never_retries_and_precancel_never_dispatches() {
4990 for xai in [false, true] {
4991 let broker = Arc::new(Broker {
4992 bodies: vec![(
4993 if xai { XAI_PARTIAL } else { CHAT_PARTIAL },
4994 Some(BrokerError::Denied("blocked".into())),
4995 )],
4996 seen: Mutex::new(vec![]),
4997 });
4998 let (result, events) =
4999 drive(broker.clone(), xai, 10, false, &CancellationToken::new()).await;
5000 assert!(result.is_err());
5001 assert_eq!(broker.seen.lock().unwrap().len(), 1);
5002 assert!(!events
5003 .iter()
5004 .any(|e| matches!(e, StreamEvent::Llm(crate::LlmEvent::ResponseReset))));
5005 let canceled = CancellationToken::new();
5006 canceled.cancel();
5007 assert!(drive(broker.clone(), xai, 10, false, &canceled)
5008 .await
5009 .0
5010 .is_err());
5011 assert_eq!(broker.seen.lock().unwrap().len(), 1);
5012 }
5013 }
5014 #[tokio::test(start_paused = true)]
5015 async fn suppressed_retry_emits_no_llm_previews() {
5016 let broker = Arc::new(Broker {
5017 bodies: vec![
5018 (CHAT_PARTIAL, Some(BrokerError::Transport("eof".into()))),
5019 (CHAT_OK, None),
5020 ],
5021 seen: Mutex::new(vec![]),
5022 });
5023 let (result, events) = drive(broker, false, 1, true, &CancellationToken::new()).await;
5024 assert!(result.is_ok());
5025 assert!(!events.iter().any(|e| matches!(e, StreamEvent::Llm(_))));
5026 }
5027 #[test]
5028 fn exhaustion_is_transient_for_auto_driver_but_not_arbitrary_error_prose() {
5029 use crate::extensions::session_driver::{classify_error, Outcome};
5030 assert_eq!(
5031 classify_error(STREAM_INTERRUPTED),
5032 (Outcome::ProviderError, "transient".into())
5033 );
5034 assert_eq!(
5035 classify_error(&format!("API error: {STREAM_INTERRUPTED}")),
5036 (Outcome::ProviderError, "transient".into())
5037 );
5038 assert_eq!(
5039 classify_error(&format!("denied: {STREAM_INTERRUPTED}")).0,
5040 Outcome::Blocked
5041 );
5042 }
5043}