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::Retry(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
101#[derive(Clone, Copy)]
106struct SendTarget<'a> {
107 label: &'a str,
108 url: &'a str,
109 quota_probe: bool,
113}
114
115async fn send_with_retries(
135 target: SendTarget<'_>,
136 build: impl Fn() -> reqwest::RequestBuilder,
137 cancel: &tokio_util::sync::CancellationToken,
138 max_retries: u32,
139 retries_used: &mut u32,
140 trace_attempt: &mut tr::StreamAttempt,
141) -> Result<reqwest::Response, Box<dyn std::error::Error + Send + Sync>> {
142 use super::account_routing::{
143 classify_codex_429, read_body_for_classification, Codex429, CodexQuotaExhausted,
144 QUOTA_PROBE_BODY_CAP,
145 };
146 let SendTarget {
147 label,
148 url,
149 quota_probe,
150 } = target;
151 loop {
152 if cancel.is_cancelled() {
153 return Err("request canceled".into());
154 }
155 let sent = tokio::select! {
156 biased;
157 _ = cancel.cancelled() => { trace_attempt.finish_canceled(None, None); return Err("request canceled".into()); }
158 sent = build().send() => sent,
159 };
160 match sent {
161 Ok(resp) if resp.status().is_success() => {
162 trace_attempt.mark_headers();
163 return Ok(resp);
164 }
165 Ok(resp) => {
166 trace_attempt.mark_headers();
170 let status = resp.status();
171 let trace_rid = tr::provider_request_id_from_headers(resp.headers());
173 let mut retryable =
174 status.as_u16() == 408 || status.as_u16() == 429 || status.is_server_error();
175 if quota_probe && status.as_u16() == 429 {
176 let headers = resp.headers().clone();
183 let body = tokio::select! {
184 biased;
185 _ = cancel.cancelled() => {
186 trace_attempt.finish_canceled(Some(429), None);
187 return Err("request canceled".into());
188 }
189 body = read_body_for_classification(resp, QUOTA_PROBE_BODY_CAP) => body,
190 };
191 match classify_codex_429(&headers, &body) {
192 Codex429::QuotaExhausted(evidence) => {
193 tracing::warn!(
194 signal = ?evidence.signal,
195 reset_after_secs = ?evidence.reset_after_secs,
196 "{label} 429 is provider-declared quota exhaustion (no transport retry)"
197 );
198 return Err(Box::new(CodexQuotaExhausted {
199 evidence,
200 request_id: trace_rid,
201 }));
202 }
203 Codex429::UsageNotIncluded => {
204 tracing::warn!("{label} 429: account has no Codex usage entitlement");
206 retryable = false;
207 }
208 Codex429::Generic => {}
209 }
210 } else if quota_probe && !retryable && status.is_client_error() {
211 let body = tokio::select! {
216 biased;
217 _ = cancel.cancelled() => {
218 trace_attempt.finish_canceled(Some(status.as_u16()), None);
219 return Err("request canceled".into());
220 }
221 body = read_body_for_classification(resp, super::codex_wire::CODEX_ERROR_BODY_CAP) => body,
222 };
223 let detail = super::codex_wire::CodexHttpErrorDetail::from_body(&body);
224 let message = detail.user_message(label, status);
225 tracing::warn!(error = %message, "{label} request rejected (identifiers only)");
226 trace_attempt.finish_failed(
227 &detail.trace_code(status.as_u16()),
228 Some(status.as_u16()),
229 trace_rid,
230 );
231 return Err(Box::new(super::codex_wire::CodexRequestRejected {
232 message,
233 detail,
234 }));
235 } else {
236 drop(resp);
243 }
244 let code = format!("http_{}", status.as_u16());
245 if !retryable || *retries_used >= max_retries {
246 trace_attempt.finish_failed(&code, Some(status.as_u16()), trace_rid);
247 return Err(format!("{label} request failed: {status}").into());
248 }
249 *retries_used += 1;
250 let delay = retry_delay(*retries_used);
251 trace_attempt.attempt_failed(
252 tr::retry_class_for_status(status.as_u16()),
253 delay,
254 Some(status.as_u16()),
255 trace_rid,
256 &code,
257 );
258 tracing::warn!(
259 "{label} API retry {retries_used}/{max_retries} after {delay:?}: {status}"
260 );
261 tokio::select! {
262 _ = tokio::time::sleep(delay) => {}
263 _ = cancel.cancelled() => {
264 trace_attempt.finish_canceled(None, None);
265 return Err("request canceled".into());
266 }
267 }
268 trace_attempt.restart_clock();
270 }
271 Err(e) => {
272 let localhost_refusal = e.is_connect() && url.contains("localhost");
273 let transient = e.is_timeout() || e.is_connect() || e.is_request();
274 if localhost_refusal || !transient || *retries_used >= max_retries {
275 tracing::warn!(
276 "{label} request failed (no retry): {}",
277 crate::core::error::error_chain_string(&e)
278 );
279 trace_attempt.finish_failed("transport_error", None, None);
280 return Err(e.into());
281 }
282 *retries_used += 1;
283 let delay = retry_delay(*retries_used);
284 trace_attempt.attempt_failed(
285 if e.is_timeout() {
286 crate::runtime::trace::RetryClass::Timeout
287 } else {
288 crate::runtime::trace::RetryClass::Other
289 },
290 delay,
291 None,
292 None,
293 "transport_error",
294 );
295 tracing::warn!(
296 "{label} transport retry {retries_used}/{max_retries} after {delay:?}: {}",
297 crate::core::error::error_chain_string(&e)
298 );
299 tokio::select! {
300 _ = tokio::time::sleep(delay) => {}
301 _ = cancel.cancelled() => {
302 trace_attempt.finish_canceled(None, None);
303 return Err("request canceled".into());
304 }
305 }
306 trace_attempt.restart_clock();
308 }
309 }
310 }
311}
312
313#[allow(clippy::too_many_arguments)]
319pub(crate) async fn call_oai_stream_inner(
320 cfg: &ProviderConfig,
321 broker: &std::sync::Arc<dyn crate::auth::CredentialBroker>,
322 tools_schema: &[Value],
323 system_prompt: &Option<String>,
324 messages: &[crate::SharedMessage],
325 tx: &mpsc::UnboundedSender<StreamEvent>,
326 temperature: Option<f32>,
327 max_tokens: Option<u32>,
328 thinking_budget: u32,
329 reasoning_level: agent_core::reasoning::ReasoningLevel,
330 cancel: &tokio_util::sync::CancellationToken,
331 trace: &crate::runtime::trace::TraceContext,
332 exact_wire_bytes: bool,
333 suppress_stream_deltas: bool,
334 max_retries: u32,
335) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
336 let (oai_tools, name_map) = translate::tools_to_oai(tools_schema);
337 let oai_messages = translate::messages_to_oai(messages, system_prompt, &name_map);
338 let tools_opt = if oai_tools.is_empty() {
339 None
340 } else {
341 Some(oai_tools)
342 };
343
344 let stream_options = if cfg.base_url.contains("googleapis.com") {
346 None
347 } else {
348 Some(StreamOptions {
349 include_usage: true,
350 })
351 };
352
353 let mut body = serde_json::Map::new();
354 body.insert("model".to_string(), json!(cfg.model.clone()));
355 body.insert("messages".to_string(), serde_json::to_value(oai_messages)?);
356 body.insert("stream".to_string(), json!(true));
357 if cfg.base_url.contains("api.openai.com") {
364 body.insert("store".to_string(), json!(false));
365 }
366 if let Some(stream_options) = stream_options {
367 body.insert(
368 "stream_options".to_string(),
369 serde_json::to_value(stream_options)?,
370 );
371 }
372 if let Some(max_tokens) = max_tokens {
373 body.insert("max_tokens".to_string(), json!(max_tokens));
374 }
375 if let Some(temperature) = temperature {
376 body.insert("temperature".to_string(), json!(temperature));
377 }
378 if let Some(tools) = tools_opt {
379 body.insert("tools".to_string(), serde_json::to_value(tools)?);
380 }
381 super::reasoning::apply_openai_reasoning_params(
382 &mut body,
383 super::reasoning::provider_for_key(&cfg.provider),
384 &cfg.model,
385 thinking_budget,
386 reasoning_level,
387 );
388 let body = Value::Object(body);
389
390 tracing::debug!(provider=%cfg.provider, model=%cfg.model, "openai stream request via broker proxy");
391
392 let (proxy_request, body_bytes) = crate::auth::ProxyRequest::post_json_exact(
398 cfg.provider.clone(),
399 "/chat/completions",
400 body,
401 true,
402 )?;
403 let tracer = tr::begin_openai_tracer(
407 trace,
408 &cfg.provider,
409 &cfg.model,
410 if exact_wire_bytes {
411 crate::runtime::trace::TransportKind::OpenAiChatCompletions
412 } else {
413 crate::runtime::trace::TransportKind::CloudProxy
414 },
415 &format!("{}/chat/completions", cfg.base_url.trim_end_matches('/')),
416 exact_wire_bytes.then_some(body_bytes.as_ref()),
417 messages,
418 system_prompt.as_deref(),
419 tools_schema,
420 tr::renamed_tool_losses(&name_map),
421 )
422 .await;
423 if let Some(tracer) = &tracer {
429 tr::capture_request_content(trace, tracer.request_id(), body_bytes.as_ref(), messages);
430 }
431 let mut attempt = tr::StreamAttempt::new(tracer);
432
433 let mut stream_retry = 0u32;
434 loop {
435 if cancel.is_cancelled() {
436 return Err("request canceled".into());
437 }
438 let stream = crate::runtime::api::await_or_cancel(
441 cancel,
442 broker.proxy_stream(proxy_request.clone()),
443 )
444 .await
445 .map_err(|_| "request canceled")?
446 .map_err(|e| {
449 format!(
450 "openai request failed: {}",
451 super::net::redact_provider_proxy_error(&e.to_string())
452 )
453 });
454 let mut stream = match stream {
455 Ok(stream) => {
456 attempt.mark_headers();
457 stream
458 }
459 Err(msg) => {
460 let status = tr::broker_error_status(&msg);
463 let code =
464 status.map_or_else(|| "broker_error".to_string(), |s| format!("http_{s}"));
465 attempt.finish_failed(&code, status, None);
466 return Err(msg.into());
467 }
468 };
469
470 if !suppress_stream_deltas {
471 let _ = tx.send(StreamEvent::Llm(crate::LlmEvent::ResponseStart));
472 }
473 let mut interrupted = false;
474 let mut saw_done = false;
475 let mut decoder = StreamDecoder::new();
476 let mut accumulated_text = String::new();
477 let mut tool_use_blocks: Vec<Value> = Vec::new();
478 let mut buf = bytes::BytesMut::with_capacity(8 * 1024);
479 let mut sink: Vec<OaiEvent> = Vec::with_capacity(4);
480 let mut trace_usage: Option<crate::runtime::trace::UsageMeta> = None;
481
482 while let Some(chunk) = tokio::select! {
483 biased;
484 _ = cancel.cancelled() => {
485 attempt.finish_canceled(None, trace_usage);
486 return Err("request canceled".into());
487 }
488 chunk = stream.next() => chunk,
489 } {
490 let chunk = match chunk {
491 Ok(chunk) => {
492 attempt.mark_first_byte();
493 chunk
494 }
495 Err(e) => {
496 if !matches!(e, crate::auth::BrokerError::Transport(_)) {
497 attempt.finish_failed("broker_error", None, None);
498 return Err(e.into());
499 }
500 interrupted = !saw_done && decoder.finish_reason.is_none();
501 break;
502 }
503 };
504 buf.extend_from_slice(&chunk);
505
506 while let Some(nl) = memchr::memchr(b'\n', &buf) {
508 let line_bytes = buf.split_to(nl + 1); let line = std::str::from_utf8(&line_bytes[..nl]).unwrap_or("");
510
511 sink.clear();
512 decoder.push_line(line, &mut sink);
513 saw_done |= sink.iter().any(|e| matches!(e, OaiEvent::Done));
514 capture_oai_trace_signals(&sink, &mut attempt, &mut trace_usage);
515 handle_events(
516 &sink,
517 tx,
518 &mut accumulated_text,
519 &mut tool_use_blocks,
520 &name_map,
521 suppress_stream_deltas,
522 );
523 }
524 }
525
526 if !interrupted && !buf.is_empty() {
528 let line = std::str::from_utf8(&buf).unwrap_or("");
529 sink.clear();
530 decoder.push_line(line, &mut sink);
531 saw_done |= sink.iter().any(|e| matches!(e, OaiEvent::Done));
532 capture_oai_trace_signals(&sink, &mut attempt, &mut trace_usage);
533 handle_events(
534 &sink,
535 tx,
536 &mut accumulated_text,
537 &mut tool_use_blocks,
538 &name_map,
539 suppress_stream_deltas,
540 );
541 }
542 if interrupted || (!saw_done && decoder.finish_reason.is_none()) {
543 if stream_retry >= max_retries {
544 attempt.finish_failed("stream_error", None, None);
545 return Err(STREAM_INTERRUPTED.into());
546 }
547 stream_retry += 1;
548 attempt.attempt_failed(
549 crate::runtime::trace::RetryClass::Network,
550 retry_delay(stream_retry),
551 None,
552 None,
553 "stream_error",
554 );
555 wait_stream_retry(
556 tx,
557 cancel,
558 stream_retry,
559 max_retries,
560 suppress_stream_deltas,
561 )
562 .await
563 .inspect_err(|_| {
564 attempt.finish_canceled(None, None);
565 })?;
566 attempt.restart_clock();
567 continue;
568 }
569 sink.clear();
570 decoder.finish(&mut sink);
571 capture_oai_trace_signals(&sink, &mut attempt, &mut trace_usage);
572 handle_events(
573 &sink,
574 tx,
575 &mut accumulated_text,
576 &mut tool_use_blocks,
577 &name_map,
578 suppress_stream_deltas,
579 );
580
581 let stop_reason = decoder
584 .finish_reason
585 .as_deref()
586 .map(tr::stop_reason_from_finish_reason);
587 attempt.finish_success(None, None, stop_reason, trace_usage);
589
590 let mut content: Vec<Value> = Vec::new();
592 if !accumulated_text.is_empty() {
593 content.push(json!({"type": "text", "text": accumulated_text}));
594 }
595 content.extend(tool_use_blocks);
596
597 return Ok(json!({
598 "role": "assistant",
599 "content": content,
600 }));
601 }
602}
603
604#[allow(clippy::too_many_arguments)]
605pub(crate) async fn call_codex_stream_inner(
606 cfg: &ProviderConfig,
607 client: &reqwest::Client,
608 broker: &std::sync::Arc<dyn crate::auth::CredentialBroker>,
609 tools_schema: &[Value],
610 system_prompt: &Option<String>,
611 messages: &[crate::SharedMessage],
612 tx: &mpsc::UnboundedSender<StreamEvent>,
613 temperature: Option<f32>,
614 max_tokens: Option<u32>,
615 reasoning_level: agent_core::reasoning::ReasoningLevel,
616 codex_request_role: crate::runtime::openai::catalog::CodexRequestRole,
617 cancel: &tokio_util::sync::CancellationToken,
618 max_retries: u32,
619 trace: &crate::runtime::trace::TraceContext,
620) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
621 let router = super::account_routing::BrokerCodexRouter::new(std::sync::Arc::clone(broker));
625 call_codex_stream_with_router(
626 cfg,
627 client,
628 &router,
629 tools_schema,
630 system_prompt,
631 messages,
632 tx,
633 temperature,
634 max_tokens,
635 reasoning_level,
636 codex_request_role,
637 cancel,
638 max_retries,
639 trace,
640 )
641 .await
642}
643
644#[derive(Clone, Copy)]
649struct FailoverRequest<'a> {
650 evidence: &'a super::account_routing::CodexQuotaEvidence,
651 output_started: bool,
655 prior_failovers: u32,
657}
658
659async fn try_codex_account_failover(
669 router: &dyn super::account_routing::CodexAccountRouter,
670 model: &str,
671 failed: &super::account_routing::PinnedAccount,
672 request: FailoverRequest<'_>,
673 tx: &mpsc::UnboundedSender<StreamEvent>,
674 cancel: &tokio_util::sync::CancellationToken,
675) -> Result<Option<super::account_routing::PinnedAccount>, super::net::BoxedProviderError> {
676 use super::account_routing::failover_gate;
677 use crate::runtime::api::await_or_cancel;
678 let FailoverRequest {
679 evidence,
680 output_started,
681 prior_failovers,
682 } = request;
683 await_or_cancel(cancel, router.report_exhausted(failed, evidence))
684 .await
685 .map_err(|_| "request canceled")?;
686 if let Err(blocked) = failover_gate(failed.auto, output_started, prior_failovers) {
687 tracing::info!(
688 account = %failed.credential,
689 reason = blocked.as_str(),
690 "codex quota exhausted; account failover not permitted"
691 );
692 return Ok(None);
693 }
694 let candidate = await_or_cancel(
695 cancel,
696 router.failover(model, failed, evidence, prior_failovers),
697 )
698 .await
699 .map_err(|_| "request canceled")?;
700 match candidate {
701 Ok(Some(next)) if next.credential != failed.credential => {
702 tracing::warn!(
703 from = %failed.credential,
704 to = %next.credential,
705 "codex quota exhausted; switching account (one-time failover, no output replayed)"
706 );
707 let _ = tx.send(StreamEvent::Session(crate::SessionEvent::Notice(format!(
708 "Codex usage quota exhausted on account '{}' — switched to account '{}' (one-time failover; no output was replayed).",
709 failed.label(),
710 next.label()
711 ))));
712 Ok(Some(next))
713 }
714 Ok(Some(_same)) => {
715 tracing::warn!(
716 account = %failed.credential,
717 "codex failover candidate is the exhausted account itself; refusing to retry"
718 );
719 Ok(None)
720 }
721 Ok(None) => {
722 tracing::info!(
723 account = %failed.credential,
724 "codex quota exhausted; no distinct account with fresh capacity"
725 );
726 Ok(None)
727 }
728 Err(e) => {
729 tracing::warn!(
730 account = %failed.credential,
731 error = %e,
732 "codex failover candidate resolution failed; not retrying"
733 );
734 Ok(None)
735 }
736 }
737}
738
739#[allow(clippy::too_many_arguments)]
740pub(crate) async fn call_codex_stream_with_router(
741 cfg: &ProviderConfig,
742 client: &reqwest::Client,
743 router: &dyn super::account_routing::CodexAccountRouter,
744 tools_schema: &[Value],
745 system_prompt: &Option<String>,
746 messages: &[crate::SharedMessage],
747 tx: &mpsc::UnboundedSender<StreamEvent>,
748 temperature: Option<f32>,
749 max_tokens: Option<u32>,
750 reasoning_level: agent_core::reasoning::ReasoningLevel,
751 codex_request_role: crate::runtime::openai::catalog::CodexRequestRole,
752 cancel: &tokio_util::sync::CancellationToken,
753 max_retries: u32,
754 trace: &crate::runtime::trace::TraceContext,
755) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
756 use crate::runtime::openai::catalog::plan_codex_execution;
759 let qualified_model = format!("{}/{}", cfg.provider, cfg.model);
760 let plan =
761 match plan_codex_execution(&qualified_model, reasoning_level, codex_request_role, None) {
762 Ok(plan) => plan,
763 Err(error) => {
764 tracing::debug!(
765 event = "codex_mode_plan",
766 qualified_model = %qualified_model,
767 requested_level = %reasoning_level,
768 runtime_role = codex_request_role.as_str(),
769 decision = "deny",
770 deny_code = error.code().as_str(),
771 network_attempted = false,
772 "Codex execution plan denied"
773 );
774 return Err(Box::new(error));
775 }
776 };
777 if plan.automatic_delegation() && !codex_has_required_delegation_tools(tools_schema) {
778 tracing::debug!(
779 event = "codex_mode_plan",
780 qualified_model = %qualified_model,
781 requested_level = %reasoning_level,
782 runtime_role = codex_request_role.as_str(),
783 decision = "deny",
784 deny_code = "ultra_requires_subagent_tools",
785 network_attempted = false,
786 "Codex execution plan denied"
787 );
788 return Err(
789 "Ultra requires subagent_start, subagent_status, and subagent_collect tools".into(),
790 );
791 }
792 tracing::debug!(
793 event = "codex_mode_plan",
794 qualified_model = %plan.qualified_model,
795 requested_level = %plan.selected_level,
796 execution_mode = plan.mode.as_str(),
797 wire_effort = plan.wire_effort_label(),
798 capability_source = plan.capability_source.map_or("none", |source| source.as_str()),
799 multi_agent_version = plan.multi_agent_version_label(),
800 runtime_role = plan.request_role.as_str(),
801 multi_agent_mode = plan.multi_agent_mode_label(),
802 automatic_delegation = plan.automatic_delegation(),
803 decision = "allow",
804 network_attempted = false,
805 "Codex execution plan allowed"
806 );
807 let mut pinned = crate::runtime::api::await_or_cancel(cancel, router.pin(&cfg.model))
813 .await
814 .map_err(|_| "request canceled")??;
815 let mut account_id = codex_account_id_for(&pinned)?;
816
817 let (tools, name_map) = translate::tools_to_responses(tools_schema);
818 let mut tools = tools;
823 let web_search = super::codex_wire::codex_web_search_for_request(
824 &cfg.model,
825 codex_request_role,
826 super::codex_wire::engine_env,
827 );
828 let mut web_search_index = web_search.as_ref().map(|_| tools.len());
832 let mut web_search_pending = false;
833 tools.extend(web_search);
834 let oai_messages = translate::messages_to_oai(messages, &None, &name_map);
839
840 let instructions = codex_instructions(system_prompt);
843 let mut input_items = codex_input_messages(oai_messages);
844 let prompt_cache_key = codex_prompt_cache_key(&instructions, input_items.first());
847 insert_codex_multi_agent_mode(&mut input_items, &plan);
848 let input = serde_json::Value::Array(input_items);
849 let mut body = build_codex_body(
850 &cfg.model,
851 &plan,
852 input,
853 instructions,
854 tools,
855 temperature,
856 max_tokens,
857 &prompt_cache_key,
858 );
859
860 let url = format!(
861 "{}/codex/responses",
862 cfg.base_url
863 .trim_end_matches('/')
864 .trim_end_matches("/codex")
865 );
866 tracing::debug!(url=%url, model=%cfg.model, "codex stream request");
867
868 let mut body_bytes = bytes::Bytes::from(serde_json::to_vec(&body)?);
874 let tracer = tr::begin_openai_tracer(
876 trace,
877 &cfg.provider,
878 &cfg.model,
879 crate::runtime::trace::TransportKind::OpenAiResponses,
880 &url,
881 Some(body_bytes.as_ref()),
882 messages,
883 system_prompt.as_deref(),
884 tools_schema,
885 tr::renamed_tool_losses(&name_map),
886 )
887 .await;
888 if let Some(tracer) = &tracer {
894 tr::capture_request_content(trace, tracer.request_id(), body_bytes.as_ref(), messages);
895 }
896 let traced = tracer.is_some();
899 let mut attempt = tr::StreamAttempt::new(tracer);
900 let mut stream_retry = 0u32;
901 let mut failovers = 0u32;
905
906 loop {
907 if cancel.is_cancelled() {
908 return Err("request canceled".into());
909 }
910 let sent = send_with_retries(
911 SendTarget {
912 label: "codex",
913 url: &url,
914 quota_probe: true,
915 },
916 || {
917 client
918 .post(&url)
919 .bearer_auth(&pinned.token)
920 .header("chatgpt-account-id", account_id.as_str())
921 .header("originator", "synaps")
922 .header("OpenAI-Beta", "responses=experimental")
923 .header("session-id", prompt_cache_key.as_str())
929 .header("thread-id", prompt_cache_key.as_str())
930 .header("content-type", "application/json")
931 .header("accept", "text/event-stream")
932 .body(body_bytes.clone())
933 },
934 cancel,
935 max_retries,
936 &mut stream_retry,
937 &mut attempt,
938 )
939 .await;
940 let resp = match sent {
941 Ok(resp) => {
942 if std::mem::take(&mut web_search_pending) {
943 super::codex_wire::confirm_web_search_rejected(&cfg.model, tx);
944 }
945 resp
946 }
947 Err(error) => match error.downcast::<super::account_routing::CodexQuotaExhausted>() {
948 Ok(quota) => {
952 match try_codex_account_failover(
953 router,
954 &cfg.model,
955 &pinned,
956 FailoverRequest {
957 evidence: "a.evidence,
958 output_started: false,
959 prior_failovers: failovers,
960 },
961 tx,
962 cancel,
963 )
964 .await?
965 {
966 Some(next) => {
967 attempt.attempt_failed(
968 crate::runtime::trace::RetryClass::RateLimited,
969 std::time::Duration::ZERO,
970 Some(429),
971 quota.request_id.clone(),
972 "account_failover",
973 );
974 account_id = codex_account_id_for(&next)?;
975 pinned = next;
976 failovers += 1;
977 attempt.restart_clock();
978 continue;
979 }
980 None => {
981 let message = quota.to_string();
982 attempt.finish_failed("quota_exhausted", Some(429), quota.request_id);
983 return Err(message.into());
984 }
985 }
986 }
987 Err(other) => {
988 super::codex_wire::hosted_search_retry(
993 other,
994 &mut web_search_index,
995 &cfg.model,
996 )?;
997 web_search_pending = true;
998 super::codex_wire::strip_web_search_tool(&mut body);
999 body_bytes = bytes::Bytes::from(serde_json::to_vec(&body)?);
1000 if traced {
1001 trace.reopen_one_shot_for_resend();
1002 }
1003 attempt = tr::StreamAttempt::new(
1004 tr::begin_openai_tracer(
1005 trace,
1006 &cfg.provider,
1007 &cfg.model,
1008 crate::runtime::trace::TransportKind::OpenAiResponses,
1009 &url,
1010 Some(body_bytes.as_ref()),
1011 messages,
1012 system_prompt.as_deref(),
1013 tools_schema,
1014 tr::renamed_tool_losses(&name_map),
1015 )
1016 .await,
1017 );
1018 continue;
1019 }
1020 },
1021 };
1022 let http_status = Some(resp.status().as_u16());
1024 let trace_rid = tr::provider_request_id_from_headers(resp.headers());
1025
1026 let _ = tx.send(StreamEvent::Llm(crate::LlmEvent::ResponseStart));
1027 let mut interrupted = false;
1028 let mut accumulated_text = String::new();
1029 let mut parser = CodexSseDecoder::default();
1030 let mut buf = bytes::BytesMut::with_capacity(8 * 1024);
1031 let mut stream = resp.bytes_stream();
1032
1033 while let Some(chunk) = tokio::select! {
1034 biased;
1035 _ = cancel.cancelled() => {
1036 attempt.finish_canceled(http_status, parser.trace_usage());
1037 return Err("request canceled".into());
1038 }
1039 chunk = stream.next() => chunk,
1040 } {
1041 let chunk = match chunk {
1042 Ok(chunk) => {
1043 attempt.mark_first_byte();
1044 chunk
1045 }
1046 Err(_) => {
1047 interrupted = !parser.terminal_success && parser.terminal_failure.is_none();
1048 break;
1049 }
1050 };
1051 buf.extend_from_slice(&chunk);
1052 while let Some(nl) = memchr::memchr(b'\n', &buf) {
1053 let line_bytes = buf.split_to(nl + 1);
1054 let line = std::str::from_utf8(&line_bytes[..nl]).unwrap_or("");
1055 parser.push_line(line, tx, &mut accumulated_text);
1056 }
1057 if parser.saw_model_event {
1058 attempt.mark_first_model_event();
1059 }
1060 }
1061 if !interrupted && !buf.is_empty() {
1062 let line = std::str::from_utf8(&buf).unwrap_or("");
1063 parser.push_line(line, tx, &mut accumulated_text);
1064 }
1065 if interrupted || (!parser.terminal_success && parser.terminal_failure.is_none()) {
1066 if stream_retry >= max_retries {
1067 attempt.finish_failed("stream_error", http_status, trace_rid);
1068 return Err(format!("Codex{RESPONSES_MISSING_TERMINAL_SUFFIX}").into());
1069 }
1070 stream_retry += 1;
1071 attempt.attempt_failed(
1072 crate::runtime::trace::RetryClass::Network,
1073 retry_delay(stream_retry),
1074 http_status,
1075 trace_rid,
1076 "stream_error",
1077 );
1078 wait_stream_retry(tx, cancel, stream_retry, max_retries, false)
1079 .await
1080 .inspect_err(|_| {
1081 attempt.finish_canceled(None, None);
1082 })?;
1083 attempt.restart_clock();
1084 continue;
1085 }
1086 parser.finish();
1087 if parser.saw_model_event {
1091 attempt.mark_first_model_event();
1092 }
1093 if let Err(failure) = parser.terminal_result() {
1094 if failure.code == "responses_quota" {
1095 let output_started =
1100 !accumulated_text.is_empty() || parser.model_produced_output();
1101 let evidence = super::account_routing::CodexQuotaEvidence {
1102 signal: super::account_routing::QuotaSignal::StreamQuotaEvent,
1103 reset_after_secs: None,
1104 };
1105 match try_codex_account_failover(
1106 router,
1107 &cfg.model,
1108 &pinned,
1109 FailoverRequest {
1110 evidence: &evidence,
1111 output_started,
1112 prior_failovers: failovers,
1113 },
1114 tx,
1115 cancel,
1116 )
1117 .await?
1118 {
1119 Some(next) => {
1120 attempt.attempt_failed(
1121 crate::runtime::trace::RetryClass::RateLimited,
1122 std::time::Duration::ZERO,
1123 http_status,
1124 trace_rid,
1125 "account_failover",
1126 );
1127 account_id = codex_account_id_for(&next)?;
1128 pinned = next;
1129 failovers += 1;
1130 attempt.restart_clock();
1131 continue;
1132 }
1133 None => {
1134 attempt.finish_failed(failure.code, http_status, trace_rid);
1135 return Err(failure.message.into());
1136 }
1137 }
1138 }
1139 if failure.code == "responses_empty" && stream_retry < max_retries {
1140 stream_retry += 1;
1141 let delay = retry_delay(stream_retry);
1142 attempt.attempt_failed(
1143 crate::runtime::trace::RetryClass::Other,
1144 delay,
1145 http_status,
1146 trace_rid,
1147 failure.code,
1148 );
1149 tracing::warn!(
1150 "codex stream retry {stream_retry}/{max_retries} after {delay:?}: completed without usable output"
1151 );
1152 wait_stream_retry(tx, cancel, stream_retry, max_retries, false)
1153 .await
1154 .inspect_err(|_| {
1155 attempt.finish_canceled(None, None);
1156 })?;
1157 attempt.restart_clock();
1158 continue;
1159 }
1160 attempt.finish_failed(failure.code, http_status, trace_rid);
1161 return Err(failure.message.into());
1162 }
1163 attempt.finish_success(http_status, trace_rid, None, parser.trace_usage());
1164
1165 let mut content: Vec<Value> = Vec::new();
1166 if !accumulated_text.is_empty() {
1167 content.push(json!({"type": "text", "text": accumulated_text}));
1168 }
1169 content.extend(translate::tool_calls_to_content_blocks(
1170 &parser.completed_tools,
1171 &name_map,
1172 ));
1173
1174 return Ok(json!({
1175 "role": "assistant",
1176 "content": content,
1177 }));
1178 }
1179}
1180
1181fn codex_account_id_for(
1185 pinned: &super::account_routing::PinnedAccount,
1186) -> Result<String, super::net::BoxedProviderError> {
1187 crate::auth::extract_codex_account_id(&pinned.token)
1188 .ok_or_else(|| {
1189 "Failed to extract ChatGPT account id from Codex token — run `synaps login --provider openai-codex`".into()
1190 })
1191}
1192
1193pub(crate) fn codex_prompt_cache_key(instructions: &str, first_item: Option<&Value>) -> String {
1207 use sha2::{Digest, Sha256};
1208 let mut hasher = Sha256::new();
1209 hasher.update(instructions.as_bytes());
1210 if let Some(item) = first_item {
1211 hasher.update(item.to_string().as_bytes());
1212 }
1213 format!("synaps-{:x}", hasher.finalize())
1214}
1215
1216#[allow(clippy::too_many_arguments)] pub(crate) fn build_codex_body(
1233 model: &str,
1234 plan: &crate::runtime::openai::catalog::CodexExecutionPlan,
1235 input: serde_json::Value,
1236 instructions: String,
1237 tools: Vec<serde_json::Value>,
1238 temperature: Option<f32>,
1239 max_tokens: Option<u32>,
1240 prompt_cache_key: &str,
1241) -> serde_json::Value {
1242 let mut body = json!({
1243 "model": model,
1244 "store": false,
1245 "stream": true,
1246 "instructions": instructions,
1247 "input": input,
1248 "tool_choice": "auto",
1249 "parallel_tool_calls": true,
1250 "include": ["reasoning.encrypted_content"],
1251 "text": { "verbosity": "medium" },
1252 "prompt_cache_key": prompt_cache_key,
1253 });
1254 if let Some(effort) = plan.wire_effort {
1255 body["reasoning"] = json!({ "effort": effort.as_str() });
1256 }
1257 if !tools.is_empty() {
1258 body["tools"] = serde_json::Value::Array(tools);
1259 }
1260 if let Some(temp) = temperature {
1261 body["temperature"] = json!(temp);
1262 }
1263 if let Some(max) = max_tokens {
1264 body["max_output_tokens"] = json!(max);
1265 }
1266 body
1267}
1268
1269const CODEX_MULTI_AGENT_MODE_OPEN: &str = "<multi_agent_mode>";
1270const CODEX_MULTI_AGENT_MODE_CLOSE: &str = "</multi_agent_mode>";
1271const 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.";
1272const 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.";
1273
1274pub(crate) const CODEX_WEB_SEARCH_ENV: &str = "SYNAPS_CODEX_WEB_SEARCH";
1277
1278pub(crate) fn codex_web_search_tool(value: Option<&str>) -> Option<Value> {
1287 match value.map(|v| v.trim().to_ascii_lowercase()).as_deref() {
1288 Some("live" | "on" | "true" | "1" | "yes") => Some(json!({ "type": "web_search" })),
1289 Some("cached") => Some(json!({ "type": "web_search", "external_web_access": false })),
1290 _ => None,
1291 }
1292}
1293
1294fn codex_multi_agent_mode_item(
1295 plan: &crate::runtime::openai::catalog::CodexExecutionPlan,
1296) -> Option<Value> {
1297 use crate::runtime::openai::catalog::CodexMultiAgentMode;
1298 let body = match plan.multi_agent_mode? {
1299 CodexMultiAgentMode::ExplicitRequestOnly => CODEX_EXPLICIT_REQUEST_ONLY_TEXT,
1300 CodexMultiAgentMode::Proactive => CODEX_PROACTIVE_MULTI_AGENT_TEXT,
1301 };
1302 Some(json!({
1303 "type": "message",
1304 "role": "developer",
1305 "content": [{
1306 "type": "input_text",
1307 "text": format!("{CODEX_MULTI_AGENT_MODE_OPEN}{body}{CODEX_MULTI_AGENT_MODE_CLOSE}"),
1308 }],
1309 }))
1310}
1311
1312fn is_codex_multi_agent_mode_item(item: &Value) -> bool {
1313 item.pointer("/content/0/text")
1314 .and_then(Value::as_str)
1315 .is_some_and(|text| {
1316 text.trim_start().starts_with(CODEX_MULTI_AGENT_MODE_OPEN)
1317 && text.trim_end().ends_with(CODEX_MULTI_AGENT_MODE_CLOSE)
1318 })
1319}
1320
1321fn insert_codex_multi_agent_mode(
1322 input: &mut Vec<Value>,
1323 plan: &crate::runtime::openai::catalog::CodexExecutionPlan,
1324) {
1325 input.retain(|item| !is_codex_multi_agent_mode_item(item));
1326 let Some(item) = codex_multi_agent_mode_item(plan) else {
1327 return;
1328 };
1329 input.insert(0, item);
1339}
1340
1341fn codex_has_required_delegation_tools(tools_schema: &[Value]) -> bool {
1342 ["subagent_start", "subagent_status", "subagent_collect"]
1343 .into_iter()
1344 .all(|required| {
1345 tools_schema.iter().any(|tool| {
1346 tool.get("name").and_then(Value::as_str) == Some(required)
1347 || tool.pointer("/function/name").and_then(Value::as_str) == Some(required)
1348 })
1349 })
1350}
1351
1352const 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]";
1353
1354fn codex_instructions(system_prompt: &Option<String>) -> String {
1355 let mut instructions = system_prompt.clone().unwrap_or_default();
1356 if instructions.contains("[Synaps autonomous harness policy]") {
1357 return instructions;
1358 }
1359 instructions.push_str(CODEX_AUTONOMOUS_LOOP_POLICY);
1360 instructions
1361}
1362
1363fn codex_input_messages(messages: Vec<ChatMessage>) -> Vec<Value> {
1364 responses_input_messages(messages, false)
1365}
1366
1367fn muse_input_messages(messages: Vec<ChatMessage>) -> Vec<Value> {
1385 responses_input_messages(messages, true)
1386}
1387
1388fn muse_assistant_phase(has_tool_calls: bool) -> &'static str {
1390 if has_tool_calls {
1391 "commentary"
1392 } else {
1393 "final_answer"
1394 }
1395}
1396
1397fn responses_input_messages(messages: Vec<ChatMessage>, assistant_phase: bool) -> Vec<Value> {
1398 let mut out = Vec::new();
1399 for msg in messages {
1400 if msg.role == "tool" {
1401 if let Some(call_id) = msg.tool_call_id {
1404 out.push(json!({
1405 "type": "function_call_output",
1406 "call_id": call_id,
1407 "output": msg.content.unwrap_or_default(),
1408 }));
1409 }
1410 continue;
1411 }
1412 let phased_text =
1416 if assistant_phase && msg.role == "assistant" && msg.content_parts.is_none() {
1417 msg.content
1418 .as_deref()
1419 .filter(|text| !text.trim().is_empty())
1420 } else {
1421 None
1422 };
1423 if let Some(text) = phased_text {
1424 out.push(json!({
1425 "type": "message",
1426 "role": "assistant",
1427 "phase": muse_assistant_phase(msg.tool_calls.as_ref().is_some_and(|c| !c.is_empty())),
1428 "content": [{"type": "output_text", "text": text}],
1429 }));
1430 } else if msg.content_parts.is_some() || msg.content.is_some() || msg.tool_calls.is_none() {
1431 let content = if let Some(parts) = msg.content_parts {
1432 Value::Array(parts.into_iter().map(|part| match part {
1433 ChatContentPart::Text { text } => json!({
1434 "type": if msg.role == "assistant" { "output_text" } else { "input_text" },
1435 "text": text,
1436 }),
1437 ChatContentPart::ImageUrl { image_url } => {
1438 let mut part = json!({"type": "input_image", "image_url": image_url.url});
1439 if let Some(detail) = image_url.detail {
1440 part["detail"] = json!(detail);
1441 }
1442 part
1443 }
1444 ChatContentPart::File { file } => json!({
1445 "type": "input_file",
1446 "filename": file.filename,
1447 "file_data": file.file_data,
1448 }),
1449 }).collect())
1450 } else {
1451 Value::String(msg.content.unwrap_or_default())
1452 };
1453 out.push(json!({"role": msg.role, "content": content}));
1454 }
1455 if let Some(tool_calls) = msg.tool_calls {
1456 for call in tool_calls {
1457 let mut item = json!({
1464 "type": "function_call",
1465 "call_id": call.id,
1466 "name": call.function.name,
1467 "arguments": call.function.arguments,
1468 });
1469 if call.id.starts_with("fc") {
1470 item["id"] = Value::from(call.id.clone());
1471 }
1472 out.push(item);
1473 }
1474 }
1475 }
1476 out
1477}
1478
1479#[derive(Clone, Debug, Eq, PartialEq)]
1480struct ResponsesStreamFailure {
1481 code: &'static str,
1482 message: String,
1485}
1486
1487impl ResponsesStreamFailure {
1488 fn is_retryable(&self) -> bool {
1492 matches!(
1493 self.code,
1494 "responses_capacity" | "responses_empty" | "responses_missing_terminal"
1495 )
1496 }
1497}
1498
1499const CAPACITY_ERROR_CODES: &[&str] = &[
1503 "rate_limit_exceeded",
1504 "server_overloaded",
1505 "overloaded",
1506 "capacity",
1507 "server_error",
1508];
1509
1510const CAPACITY_MESSAGE_PATTERNS: &[&str] = &[
1516 "at capacity",
1517 "high demand",
1518 "try again in a few minutes",
1519 "overloaded",
1520];
1521
1522fn is_capacity_failure(error_code: &str, message: Option<&str>) -> bool {
1525 if CAPACITY_ERROR_CODES.contains(&error_code) {
1526 return true;
1527 }
1528 message.is_some_and(|m| {
1529 let lower = m.to_ascii_lowercase();
1530 CAPACITY_MESSAGE_PATTERNS.iter().any(|p| lower.contains(p))
1531 })
1532}
1533
1534struct CodexSseDecoder {
1535 provider_label: &'static str,
1538 buffer: String,
1539 active_tools: Vec<CodexToolAccumulator>,
1540 completed_tools: Vec<ToolCall>,
1541 saw_model_event: bool,
1544 terminal_success: bool,
1548 terminal_failure: Option<ResponsesStreamFailure>,
1549 emitted_output: bool,
1550 saw_content_output: bool,
1557 observed_usage: Option<(u64, u64, u64)>,
1560 last_message_phase: Option<String>,
1564 last_message_index: Option<usize>,
1566 message_text: std::collections::BTreeMap<usize, String>,
1569 backfill_message_text: bool,
1573}
1574
1575impl Default for CodexSseDecoder {
1576 fn default() -> Self {
1577 Self::for_provider("Codex")
1578 }
1579}
1580
1581#[derive(Default)]
1582struct CodexToolAccumulator {
1583 id: String,
1584 name: String,
1585 arguments: String,
1586 started: bool,
1587}
1588
1589fn parse_tool_arguments(raw: &str) -> Value {
1597 if raw.trim().is_empty() {
1598 return json!({});
1599 }
1600 match serde_json::from_str(raw) {
1601 Ok(v) => v,
1602 Err(e) => json!({ "__parse_error": format!("invalid tool input JSON: {}", e) }),
1603 }
1604}
1605
1606pub(crate) fn sanitize_error_identifier(raw: Option<&str>) -> &str {
1615 match raw {
1616 None => "absent",
1617 Some(s) => {
1618 let identifier_shaped = !s.is_empty()
1619 && s.len() <= 64
1620 && s.bytes()
1621 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.');
1622 if identifier_shaped
1623 && s.bytes()
1624 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
1625 {
1626 s
1628 } else if identifier_shaped {
1629 "unlisted_identifier"
1632 } else {
1633 "unsafe_or_freeform"
1634 }
1635 }
1636 }
1637}
1638
1639pub(crate) const RESPONSES_AUTH_SUFFIX: &str =
1643 " authentication rejected in stream. Sign in again or switch to an available account.";
1644pub(crate) const RESPONSES_QUOTA_SUFFIX: &str =
1645 " usage quota exhausted in stream. Add credit, wait for reset, or switch models.";
1646pub(crate) const RESPONSES_FAILED_SUFFIX: &str =
1647 " response failed in stream. Provider error details withheld because they can echo request content.";
1648pub(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.";
1649pub(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.";
1650pub(crate) const RESPONSES_INCOMPLETE_SUFFIX: &str =
1651 " response was incomplete. Retry the request or reduce the requested output/context size.";
1652pub(crate) const RESPONSES_EMPTY_SUFFIX: &str =
1653 " completed without text or tool output. Retry the request.";
1654pub(crate) const RESPONSES_MISSING_TERMINAL_SUFFIX: &str =
1655 " response stream ended without a terminal event. Retry the request.";
1656
1657fn user_message_for_terminal_failure(
1663 label: &str,
1664 error_kind: &str,
1665 error_code: &str,
1666 capacity: bool,
1667) -> String {
1668 if error_code == "context_length_exceeded" || error_kind == "context_length_exceeded" {
1671 return format!("{label}{RESPONSES_CONTEXT_SUFFIX}");
1672 }
1673 let classify = |value: &str| match value {
1676 "absent" | "error" => None,
1677 "invalid_api_key" | "authentication_error" | "invalid_authentication_error" => Some("auth"),
1678 "insufficient_quota" | "quota_exceeded" | "billing_error" | "usage_limit_reached" => {
1679 Some("quota")
1680 }
1681 "rate_limit_exceeded"
1682 | "rate_limit_error"
1683 | "server_overloaded"
1684 | "overloaded"
1685 | "capacity"
1686 | "server_error"
1687 | "api_error"
1688 | "overloaded_error" => Some("capacity"),
1689 _ => Some("blocked"),
1690 };
1691 let (kind, code) = (classify(error_kind), classify(error_code));
1692 if kind == Some("blocked")
1693 || code == Some("blocked")
1694 || (kind.is_some() && code.is_some() && kind != code)
1695 {
1696 return format!("{label}{RESPONSES_FAILED_SUFFIX}");
1697 }
1698 match code.or(kind) {
1699 Some("auth") => return format!("{label}{RESPONSES_AUTH_SUFFIX}"),
1700 Some("quota") => return format!("{label}{RESPONSES_QUOTA_SUFFIX}"),
1701 Some("capacity") => return format!("{label}{RESPONSES_CAPACITY_SUFFIX}"),
1702 None if capacity => return format!("{label}{RESPONSES_CAPACITY_SUFFIX}"),
1705 _ => {}
1706 }
1707 format!("{label}{RESPONSES_FAILED_SUFFIX}")
1708}
1709
1710impl CodexSseDecoder {
1711 fn for_provider(label: &'static str) -> Self {
1713 Self {
1714 provider_label: label,
1715 buffer: String::new(),
1716 active_tools: Vec::new(),
1717 completed_tools: Vec::new(),
1718 saw_model_event: false,
1719 terminal_success: false,
1720 terminal_failure: None,
1721 emitted_output: false,
1722 saw_content_output: false,
1723 observed_usage: None,
1724 last_message_phase: None,
1725 last_message_index: None,
1726 message_text: std::collections::BTreeMap::new(),
1727 backfill_message_text: false,
1728 }
1729 }
1730
1731 fn observe_message_item(&mut self, idx: usize, item: &Value) {
1733 if item.get("type").and_then(Value::as_str) != Some("message") {
1734 return;
1735 }
1736 self.last_message_index = Some(idx);
1737 self.last_message_phase = item
1738 .get("phase")
1739 .and_then(Value::as_str)
1740 .map(str::to_string);
1741 }
1742
1743 fn last_message_text(&self) -> Option<&str> {
1745 self.last_message_index
1746 .and_then(|idx| self.message_text.get(&idx))
1747 .map(String::as_str)
1748 }
1749
1750 fn backfill_message_suffix(
1754 &mut self,
1755 idx: usize,
1756 item: &Value,
1757 tx: &mpsc::UnboundedSender<StreamEvent>,
1758 text_acc: &mut String,
1759 ) {
1760 if !self.backfill_message_text
1761 || item.get("type").and_then(Value::as_str) != Some("message")
1762 {
1763 return;
1764 }
1765 let Some(parts) = item.get("content").and_then(Value::as_array) else {
1766 return;
1767 };
1768 let full: String = parts
1769 .iter()
1770 .filter(|part| part.get("type").and_then(Value::as_str) == Some("output_text"))
1771 .filter_map(|part| part.get("text").and_then(Value::as_str))
1772 .collect();
1773 let streamed = self.message_text.entry(idx).or_default();
1774 if full.len() <= streamed.len() || !full.starts_with(streamed.as_str()) {
1775 return;
1776 }
1777 let suffix = full[streamed.len()..].to_string();
1778 tracing::debug!(
1779 provider = self.provider_label,
1780 suffix_bytes = suffix.len(),
1781 "backfilling message text missing from the delta stream"
1782 );
1783 streamed.push_str(&suffix);
1784 self.emitted_output = true;
1785 text_acc.push_str(&suffix);
1786 let _ = tx.send(StreamEvent::Llm(crate::runtime::types::LlmEvent::Text(
1787 suffix,
1788 )));
1789 }
1790
1791 fn emitted_any_output(&self) -> bool {
1795 self.emitted_output
1796 || !self.completed_tools.is_empty()
1797 || self.active_tools.iter().any(|tool| tool.started)
1798 }
1799
1800 fn model_produced_output(&self) -> bool {
1805 self.saw_content_output || self.emitted_any_output()
1806 }
1807
1808 fn push_line(
1809 &mut self,
1810 line: &str,
1811 tx: &mpsc::UnboundedSender<StreamEvent>,
1812 text_acc: &mut String,
1813 ) {
1814 let line = line.trim_end_matches('\r');
1815 if line.is_empty() {
1816 if !self.buffer.is_empty() {
1817 let payload = std::mem::take(&mut self.buffer);
1818 self.push_payload(&payload, tx, text_acc);
1819 }
1820 return;
1821 }
1822 let Some(data) = line.strip_prefix("data:").map(str::trim_start) else {
1823 return;
1824 };
1825 if data == "[DONE]" {
1826 self.terminal_success = true;
1827 self.finish();
1828 return;
1829 }
1830 self.buffer.push_str(data);
1831 }
1832
1833 fn push_payload(
1834 &mut self,
1835 payload: &str,
1836 tx: &mpsc::UnboundedSender<StreamEvent>,
1837 text_acc: &mut String,
1838 ) {
1839 let Ok(event) = serde_json::from_str::<Value>(payload) else {
1840 tracing::debug!(
1841 payload_bytes = payload.len(),
1842 "discarding malformed Responses SSE payload"
1843 );
1844 return;
1845 };
1846 let event_type = event
1847 .get("type")
1848 .and_then(Value::as_str)
1849 .unwrap_or_default();
1850 if matches!(
1851 event_type,
1852 "response.output_text.delta"
1853 | "response.output_item.added"
1854 | "response.function_call_arguments.delta"
1855 | "response.output_item.done"
1856 | "response.completed"
1857 | "response.done"
1858 | "response.failed"
1859 | "response.incomplete"
1860 | "error"
1861 ) {
1862 self.saw_model_event = true;
1863 }
1864 if matches!(
1868 event_type,
1869 "response.output_text.delta"
1870 | "response.output_text.done"
1871 | "response.output_item.added"
1872 | "response.output_item.done"
1873 | "response.function_call_arguments.delta"
1874 | "response.function_call_arguments.done"
1875 ) || event_type.starts_with("response.reasoning")
1876 || event_type.starts_with("response.content_part")
1877 || event_type.starts_with("response.refusal")
1878 {
1879 self.saw_content_output = true;
1880 }
1881 match event_type {
1882 "response.output_text.delta" => {
1883 if let Some(delta) = event.get("delta").and_then(Value::as_str) {
1884 if !delta.is_empty() {
1885 self.emitted_output = true;
1886 }
1887 let idx = event
1888 .get("output_index")
1889 .and_then(Value::as_u64)
1890 .unwrap_or(0) as usize;
1891 self.message_text.entry(idx).or_default().push_str(delta);
1892 text_acc.push_str(delta);
1893 let _ = tx.send(StreamEvent::Llm(crate::runtime::types::LlmEvent::Text(
1894 delta.to_string(),
1895 )));
1896 }
1897 }
1898 "response.output_item.added" => {
1899 if let Some(item) = event.get("item") {
1900 let idx = event
1901 .get("output_index")
1902 .and_then(Value::as_u64)
1903 .unwrap_or(0) as usize;
1904 self.observe_message_item(idx, item);
1905 self.add_tool_from_item(idx, item, tx);
1906 }
1907 }
1908 "response.function_call_arguments.delta" => {
1909 let idx = event
1910 .get("output_index")
1911 .and_then(Value::as_u64)
1912 .unwrap_or(0) as usize;
1913 let delta = event
1914 .get("delta")
1915 .and_then(Value::as_str)
1916 .unwrap_or_default();
1917 if !delta.is_empty() {
1918 let tool = self.ensure_tool(idx);
1919 tool.arguments.push_str(delta);
1920 let tool_id = tool.id.clone();
1921 let _ = tx.send(StreamEvent::Llm(
1922 crate::runtime::types::LlmEvent::ToolUseDelta {
1923 tool_id,
1924 delta: delta.to_string(),
1925 },
1926 ));
1927 }
1928 }
1929 "response.output_item.done" => {
1930 if let Some(item) = event.get("item") {
1931 let idx = event
1932 .get("output_index")
1933 .and_then(Value::as_u64)
1934 .unwrap_or(0) as usize;
1935 self.observe_message_item(idx, item);
1936 self.backfill_message_suffix(idx, item, tx, text_acc);
1937 super::codex_wire::announce_web_search_call(self.provider_label, item, tx);
1938 self.complete_tool_from_item(idx, item, tx);
1939 }
1940 }
1941 "response.completed" | "response.done" => {
1942 self.push_usage(&event, tx);
1943 self.terminal_success = true;
1944 self.finish();
1945 }
1946 "response.failed" | "error" => {
1947 self.push_usage(&event, tx);
1948 let error_obj = event
1959 .get("response")
1960 .and_then(|r| r.get("error"))
1961 .or_else(|| event.get("error"))
1962 .filter(|e| e.is_object());
1963 let error_kind = sanitize_error_identifier(
1964 error_obj
1965 .and_then(|e| e.get("type"))
1966 .and_then(Value::as_str),
1967 );
1968 let error_code = sanitize_error_identifier(
1969 error_obj
1970 .and_then(|e| e.get("code"))
1971 .or_else(|| event.get("code"))
1972 .and_then(Value::as_str),
1973 );
1974 let capacity = is_capacity_failure(
1977 error_code,
1978 error_obj
1979 .and_then(|e| e.get("message"))
1980 .or_else(|| event.get("message"))
1981 .and_then(Value::as_str),
1982 );
1983 tracing::warn!(
1984 provider = self.provider_label,
1985 event_type,
1986 error_kind,
1987 error_code,
1988 capacity,
1989 "responses stream terminal failure (provider message withheld)"
1990 );
1991 let message = user_message_for_terminal_failure(
1992 self.provider_label,
1993 error_kind,
1994 error_code,
1995 capacity,
1996 );
1997 self.terminal_failure = Some(ResponsesStreamFailure {
1998 code: if message
2001 == format!("{}{RESPONSES_CAPACITY_SUFFIX}", self.provider_label)
2002 {
2003 "responses_capacity"
2004 } else if super::account_routing::stream_failure_is_quota(
2005 error_kind, error_code,
2006 ) {
2007 "responses_quota"
2011 } else {
2012 "responses_failed"
2013 },
2014 message,
2015 });
2016 self.finish();
2017 }
2018 "response.incomplete" => {
2019 self.push_usage(&event, tx);
2020 let reason = sanitize_error_identifier(
2021 event
2022 .get("response")
2023 .and_then(|r| r.get("incomplete_details"))
2024 .and_then(|d| d.get("reason"))
2025 .and_then(Value::as_str),
2026 );
2027 tracing::warn!(
2028 provider = self.provider_label,
2029 reason,
2030 "responses stream terminal incomplete"
2031 );
2032 self.terminal_failure = Some(ResponsesStreamFailure {
2033 code: "responses_incomplete",
2034 message: format!("{}{RESPONSES_INCOMPLETE_SUFFIX}", self.provider_label),
2035 });
2036 self.finish();
2037 }
2038 _ => {
2039 if !event_type.is_empty() {
2040 tracing::debug!(event_type, "ignoring unknown Responses SSE event");
2041 }
2042 }
2043 }
2044 }
2045
2046 fn terminal_result(&self) -> Result<(), ResponsesStreamFailure> {
2047 if let Some(failure) = &self.terminal_failure {
2048 return Err(failure.clone());
2049 }
2050 if self.terminal_success {
2051 if self.emitted_output || !self.completed_tools.is_empty() {
2052 Ok(())
2053 } else {
2054 Err(ResponsesStreamFailure {
2055 code: "responses_empty",
2056 message: format!("{}{RESPONSES_EMPTY_SUFFIX}", self.provider_label),
2057 })
2058 }
2059 } else {
2060 Err(ResponsesStreamFailure {
2061 code: "responses_missing_terminal",
2062 message: format!("{}{RESPONSES_MISSING_TERMINAL_SUFFIX}", self.provider_label),
2063 })
2064 }
2065 }
2066
2067 fn ensure_tool(&mut self, idx: usize) -> &mut CodexToolAccumulator {
2068 const MAX_TOOLS: usize = 4096;
2073 let idx = idx.min(MAX_TOOLS - 1);
2074 while self.active_tools.len() <= idx {
2075 self.active_tools.push(CodexToolAccumulator::default());
2076 }
2077 &mut self.active_tools[idx]
2078 }
2079
2080 fn add_tool_from_item(
2081 &mut self,
2082 idx: usize,
2083 item: &Value,
2084 tx: &mpsc::UnboundedSender<StreamEvent>,
2085 ) {
2086 if item.get("type").and_then(Value::as_str) != Some("function_call") {
2087 return;
2088 }
2089 let tool = self.ensure_tool(idx);
2090 if let Some(id) = item
2091 .get("call_id")
2092 .or_else(|| item.get("id"))
2093 .and_then(Value::as_str)
2094 {
2095 tool.id = id.to_string();
2096 }
2097 if let Some(name) = item.get("name").and_then(Value::as_str) {
2098 tool.name = name.to_string();
2099 }
2100 if !tool.started && !tool.name.is_empty() {
2101 tool.started = true;
2102 let _ = tx.send(StreamEvent::Llm(
2103 crate::runtime::types::LlmEvent::ToolUseStart {
2104 tool_name: tool.name.clone(),
2105 tool_id: tool.id.clone(),
2106 },
2107 ));
2108 }
2109 }
2110
2111 fn complete_tool_from_item(
2112 &mut self,
2113 idx: usize,
2114 item: &Value,
2115 tx: &mpsc::UnboundedSender<StreamEvent>,
2116 ) {
2117 if item.get("type").and_then(Value::as_str) != Some("function_call") {
2118 return;
2119 }
2120 let tool = self.ensure_tool(idx);
2121 if let Some(id) = item
2122 .get("call_id")
2123 .or_else(|| item.get("id"))
2124 .and_then(Value::as_str)
2125 {
2126 tool.id = id.to_string();
2127 }
2128 if let Some(name) = item.get("name").and_then(Value::as_str) {
2129 tool.name = name.to_string();
2130 }
2131 if let Some(arguments) = item.get("arguments").and_then(Value::as_str) {
2132 tool.arguments = arguments.to_string();
2133 }
2134 if !tool.started && !tool.name.is_empty() {
2135 tool.started = true;
2136 let _ = tx.send(StreamEvent::Llm(
2137 crate::runtime::types::LlmEvent::ToolUseStart {
2138 tool_name: tool.name.clone(),
2139 tool_id: tool.id.clone(),
2140 },
2141 ));
2142 }
2143 let completed = if !tool.id.is_empty() && !tool.name.is_empty() {
2144 Some(ToolCall {
2145 id: tool.id.clone(),
2146 kind: "function".to_string(),
2147 function: super::types::FunctionCall {
2148 name: tool.name.clone(),
2149 arguments: tool.arguments.clone(),
2150 },
2151 })
2152 } else {
2153 None
2154 };
2155 if let Some(call) = completed {
2156 if self.completed_tools.iter().any(|done| done.id == call.id) {
2157 return;
2158 }
2159 self.emitted_output = true;
2160 let input = parse_tool_arguments(&call.function.arguments);
2168 let _ = tx.send(StreamEvent::Llm(crate::runtime::types::LlmEvent::ToolUse {
2169 tool_name: call.function.name.clone(),
2170 tool_id: call.id.clone(),
2171 input,
2172 }));
2173 self.completed_tools.push(ToolCall {
2174 id: call.id,
2175 kind: call.kind,
2176 function: call.function,
2177 });
2178 }
2179 }
2180
2181 fn push_usage(&mut self, event: &Value, tx: &mpsc::UnboundedSender<StreamEvent>) {
2182 let usage = event
2183 .get("response")
2184 .and_then(|r| r.get("usage"))
2185 .or_else(|| event.get("usage"));
2186 let input = usage
2187 .and_then(|u| u.get("input_tokens"))
2188 .and_then(Value::as_u64)
2189 .unwrap_or(0);
2190 let output = usage
2191 .and_then(|u| u.get("output_tokens"))
2192 .and_then(Value::as_u64)
2193 .unwrap_or(0);
2194 let cached = usage
2201 .and_then(|u| u.pointer("/input_tokens_details/cached_tokens"))
2202 .and_then(Value::as_u64)
2203 .unwrap_or(0)
2204 .min(input);
2205 if input > 0 || output > 0 {
2206 if self.observed_usage.is_none() {
2209 self.observed_usage = Some((input, output, cached));
2210 }
2211 let _ = tx.send(StreamEvent::Session(
2212 crate::runtime::types::SessionEvent::Usage {
2213 input_tokens: input - cached,
2214 output_tokens: output,
2215 cache_read_input_tokens: cached,
2216 cache_creation_input_tokens: 0,
2217 cache_creation_5m: None,
2218 cache_creation_1h: None,
2219 model: None,
2220 },
2221 ));
2222 }
2223 }
2224
2225 fn trace_usage(&self) -> Option<crate::runtime::trace::UsageMeta> {
2227 self.observed_usage
2228 .map(|(input, output, cached)| tr::provider_usage(input, output, cached))
2229 }
2230
2231 fn finish(&mut self) {
2232 for tool in self.active_tools.drain(..) {
2233 if !tool.id.is_empty()
2234 && !tool.name.is_empty()
2235 && !self.completed_tools.iter().any(|done| done.id == tool.id)
2236 {
2237 self.emitted_output = true;
2238 self.completed_tools.push(ToolCall {
2239 id: tool.id,
2240 kind: "function".to_string(),
2241 function: super::types::FunctionCall {
2242 name: tool.name,
2243 arguments: tool.arguments,
2244 },
2245 });
2246 }
2247 }
2248 }
2249}
2250
2251fn handle_events(
2252 events: &[OaiEvent],
2253 tx: &mpsc::UnboundedSender<StreamEvent>,
2254 text_acc: &mut String,
2255 tool_blocks: &mut Vec<Value>,
2256 name_map: &translate::ToolNameMap,
2257 suppress_stream_deltas: bool,
2258) {
2259 for ev in events {
2260 if let OaiEvent::TextDelta(t) = ev {
2261 text_acc.push_str(t);
2262 }
2263 if let OaiEvent::ToolCallsComplete { calls, .. } = ev {
2264 tool_blocks.extend(translate::tool_calls_to_content_blocks(calls, name_map));
2265 }
2266 if !suppress_stream_deltas {
2267 if let Some(se) = translate::oai_event_to_llm(ev) {
2268 let _ = tx.send(se);
2269 }
2270 }
2271 }
2272}
2273
2274#[cfg(test)]
2275mod codex_input_messages_tests {
2276 use super::super::types::{ChatMessage, FunctionCall, ToolCall};
2292 use super::*;
2293
2294 fn sample_tool_call() -> ToolCall {
2295 ToolCall {
2296 id: "call_nZYquCuGUh8Qs9H51dwHMDgs".to_string(),
2297 kind: "function".to_string(),
2298 function: FunctionCall {
2299 name: "bash".to_string(),
2300 arguments: r#"{"command":"ls"}"#.to_string(),
2301 },
2302 }
2303 }
2304
2305 fn translated_input(messages: Vec<Value>) -> Vec<Value> {
2306 let messages: Vec<crate::SharedMessage> =
2307 messages.into_iter().map(std::sync::Arc::new).collect();
2308 codex_input_messages(translate::messages_to_oai(
2309 &messages,
2310 &None,
2311 &translate::ToolNameMap::default(),
2312 ))
2313 }
2314
2315 #[test]
2316 fn image_text_document_and_pdf_exact_responses_wire() {
2317 let out = translated_input(vec![json!({"role":"user","content":[
2318 {"type":"text","text":"Compare these."},
2319 {"type":"image","source":{"type":"base64","media_type":"image/png","data":"aW1hZ2U="}},
2320 {"type":"document","title":"notes.txt","source":{"type":"text","media_type":"text/plain","data":"Text document\ncontents"}},
2321 {"type":"document","title":"report.pdf","source":{"type":"base64","media_type":"application/pdf","data":"JVBERi0="}},
2322 {"type":"text","text":"Then summarize."}
2323 ]})]);
2324 assert_eq!(
2325 out,
2326 vec![json!({"role":"user","content":[
2327 {"type":"input_text","text":"Compare these."},
2328 {"type":"input_text","text":"[attachment origin=user type=image media_type=\"image/png\"; lower-authority data]\n"},
2329 {"type":"input_image","image_url":"data:image/png;base64,aW1hZ2U="},
2330 {"type":"input_text","text":"[/attachment]\n"},
2331 {"type":"input_text","text":"[attachment origin=user type=document media_type=\"text/plain\" filename=\"notes.txt\"; lower-authority data]\n"},
2332 {"type":"input_text","text":"Text document\ncontents"},
2333 {"type":"input_text","text":"[/attachment]\n"},
2334 {"type":"input_text","text":"[attachment origin=user type=document media_type=\"application/pdf\" filename=\"report.pdf\"; lower-authority data]\n"},
2335 {"type":"input_file","filename":"report.pdf","file_data":"data:application/pdf;base64,JVBERi0="},
2336 {"type":"input_text","text":"[/attachment]\n"},
2337 {"type":"input_text","text":"Then summarize."}
2338 ]})]
2339 );
2340 }
2341
2342 #[test]
2343 fn mixed_assistant_text_and_all_tool_outputs_precede_lifted_responses_media() {
2344 let out = translated_input(vec![
2345 json!({"role":"assistant","content":[
2346 {"type":"text","text":"Reading both."},
2347 {"type":"tool_use","id":"t1","name":"read","input":{}},
2348 {"type":"tool_use","id":"t2","name":"read","input":{}}
2349 ]}),
2350 json!({"role":"user","content":[
2351 {"type":"tool_result","tool_use_id":"t1","content":[
2352 {"type":"text","text":"First result"},
2353 {"type":"image","source":{"type":"base64","media_type":"image/png","data":"aW1hZ2U="}},
2354 {"type":"document","title":"notes.txt","source":{"type":"text","media_type":"text/plain","data":"DOCUMENT_BODY_SENTINEL"}}
2355 ]},
2356 {"type":"tool_result","tool_use_id":"t2","content":[
2357 {"type":"text","text":"Second result"},
2358 {"type":"document","title":"report.pdf","source":{"type":"base64","media_type":"application/pdf","data":"JVBERi0="}}
2359 ]}
2360 ]}),
2361 ]);
2362 assert_eq!(
2363 out,
2364 vec![
2365 json!({"role":"assistant","content":"Reading both."}),
2366 json!({"type":"function_call","call_id":"t1","name":"read","arguments":"{}"}),
2367 json!({"type":"function_call","call_id":"t2","name":"read","arguments":"{}"}),
2368 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"}),
2369 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"}),
2370 json!({"role":"user","content":[
2371 {"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"},
2372 {"type":"input_image","image_url":"data:image/png;base64,aW1hZ2U="},
2373 {"type":"input_text","text":"[/attachment]\n"},
2374 {"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"},
2375 {"type":"input_text","text":"DOCUMENT_BODY_SENTINEL"},
2376 {"type":"input_text","text":"[/attachment]\n"},
2377 {"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"},
2378 {"type":"input_file","filename":"report.pdf","file_data":"data:application/pdf;base64,JVBERi0="},
2379 {"type":"input_text","text":"[/attachment]\n"}
2380 ]})
2381 ]
2382 );
2383 for output in out
2384 .iter()
2385 .filter(|item| item["type"] == "function_call_output")
2386 {
2387 let text = output["output"].as_str().unwrap();
2388 for sentinel in [
2389 "aW1hZ2U=",
2390 "JVBERi0=",
2391 "DOCUMENT_BODY_SENTINEL",
2392 "\"source\"",
2393 ] {
2394 assert!(
2395 !text.contains(sentinel),
2396 "tool output must contain only text and metadata"
2397 );
2398 }
2399 }
2400 }
2401
2402 #[test]
2403 fn text_only_responses_input_keeps_strings_and_mixed_assistant_text() {
2404 let mut assistant = ChatMessage::assistant_tool_calls(vec![sample_tool_call()]);
2405 assistant.content = Some("Checking.".into());
2406 let out = codex_input_messages(vec![
2407 ChatMessage::system("rules"),
2408 ChatMessage::user("question"),
2409 assistant,
2410 ChatMessage::tool_result("call_nZYquCuGUh8Qs9H51dwHMDgs", "bash", "done"),
2411 ChatMessage::assistant("answer"),
2412 ]);
2413 assert_eq!(
2414 out,
2415 vec![
2416 json!({"role":"system","content":"rules"}),
2417 json!({"role":"user","content":"question"}),
2418 json!({"role":"assistant","content":"Checking."}),
2419 json!({"type":"function_call","call_id":"call_nZYquCuGUh8Qs9H51dwHMDgs","name":"bash","arguments":"{\"command\":\"ls\"}"}),
2420 json!({"type":"function_call_output","call_id":"call_nZYquCuGUh8Qs9H51dwHMDgs","output":"done"}),
2421 json!({"role":"assistant","content":"answer"}),
2422 ]
2423 );
2424 }
2425
2426 fn phase_history() -> Vec<Value> {
2430 vec![
2431 json!({"role":"user","content":"build it"}),
2432 json!({"role":"assistant","content":[
2433 {"type":"thinking","thinking":"hidden","signature":"s"},
2434 {"type":"text","text":"Mapping the repo first."},
2435 {"type":"tool_use","id":"call_a","name":"bash","input":{"command":"ls"}},
2436 ]}),
2437 json!({"role":"user","content":[
2438 {"type":"tool_result","tool_use_id":"call_a","content":"src"},
2439 ]}),
2440 json!({"role":"assistant","content":[
2441 {"type":"tool_use","id":"call_b","name":"bash","input":{"command":"pwd"}},
2442 ]}),
2443 json!({"role":"user","content":[
2444 {"type":"tool_result","tool_use_id":"call_b","content":"/w"},
2445 ]}),
2446 json!({"role":"user","content":"also check logs"}),
2447 json!({"role":"assistant","content":[{"type":"text","text":"Done: all good."}]}),
2448 json!({"role":"user","content":"thanks"}),
2449 json!({"role":"assistant","content":"Plain string answer."}),
2450 ]
2451 }
2452
2453 fn oai(messages: Vec<Value>) -> Vec<ChatMessage> {
2454 let messages: Vec<crate::SharedMessage> =
2455 messages.into_iter().map(std::sync::Arc::new).collect();
2456 translate::messages_to_oai(&messages, &None, &translate::ToolNameMap::default())
2457 }
2458
2459 #[test]
2460 fn muse_input_infers_phase_from_message_structure() {
2461 let out = muse_input_messages(oai(phase_history()));
2462 assert_eq!(
2463 out,
2464 vec![
2465 json!({"role":"user","content":"build it"}),
2466 json!({"type":"message","role":"assistant","phase":"commentary",
2468 "content":[{"type":"output_text","text":"Mapping the repo first."}]}),
2469 json!({"type":"function_call","call_id":"call_a","name":"bash","arguments":"{\"command\":\"ls\"}"}),
2470 json!({"type":"function_call_output","call_id":"call_a","output":"src"}),
2471 json!({"type":"function_call","call_id":"call_b","name":"bash","arguments":"{\"command\":\"pwd\"}"}),
2473 json!({"type":"function_call_output","call_id":"call_b","output":"/w"}),
2474 json!({"role":"assistant","content":" "}),
2476 json!({"role":"user","content":"also check logs"}),
2477 json!({"type":"message","role":"assistant","phase":"final_answer",
2479 "content":[{"type":"output_text","text":"Done: all good."}]}),
2480 json!({"role":"user","content":"thanks"}),
2481 json!({"type":"message","role":"assistant","phase":"final_answer",
2482 "content":[{"type":"output_text","text":"Plain string answer."}]}),
2483 ]
2484 );
2485 }
2486
2487 #[test]
2488 fn codex_and_xai_input_stay_phase_less_for_the_same_history() {
2489 let out = codex_input_messages(oai(phase_history()));
2492 assert_eq!(
2493 out,
2494 vec![
2495 json!({"role":"user","content":"build it"}),
2496 json!({"role":"assistant","content":"Mapping the repo first."}),
2497 json!({"type":"function_call","call_id":"call_a","name":"bash","arguments":"{\"command\":\"ls\"}"}),
2498 json!({"type":"function_call_output","call_id":"call_a","output":"src"}),
2499 json!({"type":"function_call","call_id":"call_b","name":"bash","arguments":"{\"command\":\"pwd\"}"}),
2500 json!({"type":"function_call_output","call_id":"call_b","output":"/w"}),
2501 json!({"role":"assistant","content":" "}),
2502 json!({"role":"user","content":"also check logs"}),
2503 json!({"role":"assistant","content":"Done: all good."}),
2504 json!({"role":"user","content":"thanks"}),
2505 json!({"role":"assistant","content":"Plain string answer."}),
2506 ]
2507 );
2508 assert!(!serde_json::to_string(&out).unwrap().contains("phase"));
2509 }
2510
2511 #[test]
2512 fn muse_input_leaves_non_assistant_and_tool_only_messages_identical() {
2513 let messages = || {
2514 vec![
2515 ChatMessage::system("rules"),
2516 ChatMessage::user("question"),
2517 ChatMessage::assistant_tool_calls(vec![sample_tool_call()]),
2518 ChatMessage::tool_result("call_nZYquCuGUh8Qs9H51dwHMDgs", "bash", "done"),
2519 ]
2520 };
2521 assert_eq!(
2522 muse_input_messages(messages()),
2523 codex_input_messages(messages())
2524 );
2525 }
2526
2527 #[test]
2528 fn codex_instructions_appends_autonomous_loop_policy() {
2529 let instructions = codex_instructions(&Some("Project-specific rules.".to_string()));
2530 assert!(instructions.contains("Project-specific rules."));
2531 assert!(instructions.contains("Do not stop at phase boundaries"));
2532 assert!(instructions.contains("Do not ask the user whether to continue"));
2533 assert!(
2534 instructions.contains("continue autonomously until the full requested job is complete")
2535 );
2536 }
2537
2538 #[test]
2539 fn function_call_input_omits_non_fc_id() {
2540 let messages = vec![ChatMessage::assistant_tool_calls(vec![sample_tool_call()])];
2541 let out = codex_input_messages(messages);
2542 assert_eq!(out.len(), 1, "one tool_call → one input item");
2543 let item = &out[0];
2544 assert_eq!(
2545 item.get("type").and_then(Value::as_str),
2546 Some("function_call")
2547 );
2548 assert!(
2549 item.get("id").is_none(),
2550 "must not echo a non-`fc_` id back; got {:?}",
2551 item.get("id"),
2552 );
2553 assert_eq!(
2554 item.get("call_id").and_then(Value::as_str),
2555 Some("call_nZYquCuGUh8Qs9H51dwHMDgs"),
2556 );
2557 assert_eq!(item.get("name").and_then(Value::as_str), Some("bash"));
2558 }
2559
2560 #[test]
2561 fn function_call_input_keeps_real_fc_id() {
2562 let mut call = sample_tool_call();
2565 call.id = "fc_abc123".to_string();
2566 let messages = vec![ChatMessage::assistant_tool_calls(vec![call])];
2567 let out = codex_input_messages(messages);
2568 let item = &out[0];
2569 assert_eq!(item.get("id").and_then(Value::as_str), Some("fc_abc123"));
2570 assert_eq!(
2571 item.get("call_id").and_then(Value::as_str),
2572 Some("fc_abc123")
2573 );
2574 }
2575
2576 #[test]
2577 fn function_call_output_round_trips_call_id() {
2578 let messages = vec![ChatMessage::tool_result(
2580 "call_nZYquCuGUh8Qs9H51dwHMDgs",
2581 "bash",
2582 "total 0",
2583 )];
2584 let out = codex_input_messages(messages);
2585 let item = &out[0];
2586 assert_eq!(
2587 item.get("type").and_then(Value::as_str),
2588 Some("function_call_output"),
2589 );
2590 assert_eq!(
2591 item.get("call_id").and_then(Value::as_str),
2592 Some("call_nZYquCuGUh8Qs9H51dwHMDgs"),
2593 );
2594 assert_eq!(item.get("output").and_then(Value::as_str), Some("total 0"));
2595 }
2596}
2597
2598#[cfg(test)]
2599mod codex_decoder_tests {
2600 use super::*;
2607 use crate::runtime::types::{LlmEvent, SessionEvent, StreamEvent};
2608
2609 fn collect_events(rx: &mut mpsc::UnboundedReceiver<StreamEvent>) -> Vec<StreamEvent> {
2610 let mut out = Vec::new();
2611 while let Ok(ev) = rx.try_recv() {
2612 out.push(ev);
2613 }
2614 out
2615 }
2616
2617 fn drive(lines: &[&str]) -> (CodexSseDecoder, String, Vec<StreamEvent>) {
2618 let (tx, mut rx) = mpsc::unbounded_channel();
2619 let mut decoder = CodexSseDecoder::default();
2620 let mut text_acc = String::new();
2621 for line in lines {
2622 decoder.push_line(line, &tx, &mut text_acc);
2623 }
2624 let events = collect_events(&mut rx);
2625 (decoder, text_acc, events)
2626 }
2627
2628 fn drive_with(
2629 mut decoder: CodexSseDecoder,
2630 lines: &[&str],
2631 ) -> (CodexSseDecoder, String, Vec<StreamEvent>) {
2632 let (tx, mut rx) = mpsc::unbounded_channel();
2633 let mut text_acc = String::new();
2634 for line in lines {
2635 decoder.push_line(line, &tx, &mut text_acc);
2636 }
2637 let events = collect_events(&mut rx);
2638 (decoder, text_acc, events)
2639 }
2640
2641 #[test]
2642 fn decoder_captures_phase_of_the_last_message_item() {
2643 let (decoder, text, _) = drive(&[
2645 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"reasoning","id":"rs_0"}}"#,
2646 "",
2647 r#"data: {"type":"response.output_item.added","output_index":1,"item":{"type":"message","role":"assistant","phase":"commentary","content":[]}}"#,
2648 "",
2649 r#"data: {"type":"response.output_text.delta","output_index":1,"delta":"Mapping first."}"#,
2650 "",
2651 r#"data: {"type":"response.output_item.done","output_index":1,"item":{"type":"message","role":"assistant","phase":"commentary","content":[{"type":"output_text","text":"Mapping first."}]}}"#,
2652 "",
2653 r#"data: {"type":"response.output_item.added","output_index":2,"item":{"type":"function_call","call_id":"call_1","name":"bash"}}"#,
2654 "",
2655 ]);
2656 assert_eq!(decoder.last_message_phase.as_deref(), Some("commentary"));
2657 assert_eq!(decoder.last_message_index, Some(1));
2658 assert_eq!(decoder.last_message_text(), Some("Mapping first."));
2659 assert_eq!(text, "Mapping first.");
2660
2661 let (decoder, _, _) = drive(&[
2663 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","role":"assistant","phase":"commentary","content":[]}}"#,
2664 "",
2665 r#"data: {"type":"response.output_item.added","output_index":1,"item":{"type":"message","role":"assistant","content":[]}}"#,
2666 "",
2667 r#"data: {"type":"response.output_text.delta","output_index":1,"delta":"Answer"}"#,
2668 "",
2669 ]);
2670 assert_eq!(decoder.last_message_phase, None);
2671 assert_eq!(decoder.last_message_text(), Some("Answer"));
2672 }
2673
2674 const TRUNCATED_STREAM: [&str; 6] = [
2675 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","role":"assistant","content":[]}}"#,
2676 "",
2677 r#"data: {"type":"response.output_text.delta","output_index":0,"delta":"Key facts:"}"#,
2678 "",
2679 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Key facts:\nWhich format?"}]}}"#,
2680 "",
2681 ];
2682
2683 #[test]
2684 fn muse_decoder_backfills_unstreamed_message_suffix_once() {
2685 let mut decoder = CodexSseDecoder::for_provider("Meta Muse");
2686 decoder.backfill_message_text = true;
2687 let mut lines = TRUNCATED_STREAM.to_vec();
2688 lines.extend_from_slice(&TRUNCATED_STREAM[4..]);
2690 let (decoder, text, events) = drive_with(decoder, &lines);
2691 assert_eq!(text, "Key facts:\nWhich format?");
2692 let texts: Vec<_> = events
2693 .iter()
2694 .filter_map(|e| match e {
2695 StreamEvent::Llm(LlmEvent::Text(t)) => Some(t.as_str()),
2696 _ => None,
2697 })
2698 .collect();
2699 assert_eq!(texts, vec!["Key facts:", "\nWhich format?"]);
2700 assert_eq!(
2701 decoder.last_message_text(),
2702 Some("Key facts:\nWhich format?")
2703 );
2704 }
2705
2706 #[test]
2707 fn backfill_is_off_by_default_and_never_rewrites_divergent_text() {
2708 let (_, text, _) = drive(&TRUNCATED_STREAM);
2710 assert_eq!(text, "Key facts:");
2711 let mut decoder = CodexSseDecoder::for_provider("Meta Muse");
2713 decoder.backfill_message_text = true;
2714 let (_, text, _) = drive_with(
2715 decoder,
2716 &[
2717 r#"data: {"type":"response.output_text.delta","output_index":0,"delta":"Hello"}"#,
2718 "",
2719 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","content":[{"type":"output_text","text":"Goodbye world"}]}}"#,
2720 "",
2721 ],
2722 );
2723 assert_eq!(text, "Hello");
2724 }
2725
2726 #[test]
2727 fn hostile_output_index_does_not_grow_active_tools_unbounded() {
2728 let lines = [
2733 r#"data: {"type":"response.function_call_arguments.delta","output_index":1000000000,"delta":"x"}"#,
2734 "",
2735 ];
2736 let (decoder, _text, _events) = drive(&lines);
2737 assert!(
2738 decoder.active_tools.len() <= 4096,
2739 "active_tools grew to {} — output_index not bounded",
2740 decoder.active_tools.len()
2741 );
2742 }
2743
2744 #[test]
2745 fn text_delta_aggregates_into_text_acc_and_emits_text_events() {
2746 let lines = [
2747 r#"data: {"type":"response.output_text.delta","delta":"Hello, "}"#,
2748 "",
2749 r#"data: {"type":"response.output_text.delta","delta":"world!"}"#,
2750 "",
2751 ];
2752 let (_decoder, text_acc, events) = drive(&lines);
2753 assert_eq!(text_acc, "Hello, world!");
2754 let texts: Vec<_> = events
2755 .iter()
2756 .filter_map(|e| match e {
2757 StreamEvent::Llm(LlmEvent::Text(t)) => Some(t.as_str()),
2758 _ => None,
2759 })
2760 .collect();
2761 assert_eq!(texts, vec!["Hello, ", "world!"]);
2762 }
2763
2764 #[test]
2765 fn hosted_web_search_items_are_not_tool_uses() {
2766 let lines = [
2771 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"web_search_call","id":"ws_1","status":"in_progress"}}"#,
2772 "",
2773 r#"data: {"type":"response.web_search_call.searching","output_index":0,"item_id":"ws_1"}"#,
2774 "",
2775 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"web_search_call","id":"ws_1","status":"completed","action":{"type":"search","query":"ram dass puja chat youtube"}}}"#,
2776 "",
2777 r#"data: {"type":"response.output_item.added","output_index":1,"item":{"type":"function_call","call_id":"call_1","name":"get_asset"}}"#,
2778 "",
2779 r#"data: {"type":"response.output_item.done","output_index":1,"item":{"type":"function_call","call_id":"call_1","name":"get_asset","arguments":"{}"}}"#,
2780 "",
2781 r#"data: {"type":"response.output_text.delta","output_index":2,"delta":"Found it."}"#,
2782 "",
2783 r#"data: {"type":"response.output_text.annotation.added","output_index":2,"annotation":{"type":"url_citation","url":"https://www.youtube.com/watch?v=sH-u96rpYSw"}}"#,
2784 "",
2785 ];
2786 let (decoder, text, events) = drive(&lines);
2787 assert_eq!(text, "Found it.");
2788 assert_eq!(decoder.completed_tools.len(), 1);
2789 assert_eq!(decoder.completed_tools[0].function.name, "get_asset");
2790 let starts = events
2791 .iter()
2792 .filter(|e| matches!(e, StreamEvent::Llm(LlmEvent::ToolUseStart { .. })))
2793 .count();
2794 assert_eq!(starts, 1, "web_search_call must not emit a ToolUseStart");
2795 }
2796
2797 #[test]
2798 fn web_search_env_value_maps_to_hosted_tool() {
2799 use super::codex_web_search_tool;
2800 assert_eq!(codex_web_search_tool(None), None);
2801 assert_eq!(codex_web_search_tool(Some("")), None);
2802 assert_eq!(codex_web_search_tool(Some("off")), None);
2803 assert_eq!(codex_web_search_tool(Some("bogus")), None);
2804 for on in ["live", "LIVE", " on ", "true", "1", "yes"] {
2805 assert_eq!(
2806 codex_web_search_tool(Some(on)),
2807 Some(serde_json::json!({ "type": "web_search" })),
2808 "{on:?}"
2809 );
2810 }
2811 assert_eq!(
2812 codex_web_search_tool(Some("cached")),
2813 Some(serde_json::json!({ "type": "web_search", "external_web_access": false }))
2814 );
2815 }
2816
2817 #[test]
2818 fn single_function_call_completes_via_output_item_done() {
2819 let lines = [
2820 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_abc","name":"bash"}}"#,
2821 "",
2822 r#"data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"{\"cmd\""}"#,
2823 "",
2824 r#"data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":":\"ls\"}"}"#,
2825 "",
2826 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_abc","name":"bash","arguments":"{\"cmd\":\"ls\"}"}}"#,
2827 "",
2828 ];
2829 let (decoder, _text, events) = drive(&lines);
2830
2831 assert_eq!(decoder.completed_tools.len(), 1);
2832 let tool = &decoder.completed_tools[0];
2833 assert_eq!(tool.id, "call_abc");
2834 assert_eq!(tool.function.name, "bash");
2835 assert_eq!(tool.function.arguments, r#"{"cmd":"ls"}"#);
2836
2837 let starts: Vec<_> = events
2839 .iter()
2840 .filter_map(|e| match e {
2841 StreamEvent::Llm(LlmEvent::ToolUseStart { tool_name, tool_id }) => {
2842 Some((tool_name.as_str(), tool_id.as_str()))
2843 }
2844 _ => None,
2845 })
2846 .collect();
2847 assert_eq!(
2848 starts,
2849 vec![("bash", "call_abc")],
2850 "exactly one ToolUseStart with correct tool_id"
2851 );
2852
2853 let deltas: Vec<_> = events
2856 .iter()
2857 .filter_map(|e| match e {
2858 StreamEvent::Llm(LlmEvent::ToolUseDelta { tool_id, delta }) => {
2859 Some((tool_id.as_str(), delta.as_str()))
2860 }
2861 _ => None,
2862 })
2863 .collect();
2864 assert_eq!(
2865 deltas,
2866 vec![("call_abc", r#"{"cmd""#), ("call_abc", r#":"ls"}"#)]
2867 );
2868 }
2869
2870 #[test]
2871 fn parallel_tool_calls_indexed_separately() {
2872 let lines = [
2873 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"bash"}}"#,
2874 "",
2875 r#"data: {"type":"response.output_item.added","output_index":1,"item":{"type":"function_call","call_id":"call_2","name":"read"}}"#,
2876 "",
2877 r#"data: {"type":"response.function_call_arguments.delta","output_index":1,"delta":"{\"path\":\"a\"}"}"#,
2878 "",
2879 r#"data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"{\"cmd\":\"ls\"}"}"#,
2880 "",
2881 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"bash","arguments":"{\"cmd\":\"ls\"}"}}"#,
2882 "",
2883 r#"data: {"type":"response.output_item.done","output_index":1,"item":{"type":"function_call","call_id":"call_2","name":"read","arguments":"{\"path\":\"a\"}"}}"#,
2884 "",
2885 ];
2886 let (decoder, _text, _events) = drive(&lines);
2887
2888 assert_eq!(decoder.completed_tools.len(), 2);
2889 let mut by_id: std::collections::BTreeMap<&str, &ToolCall> =
2890 std::collections::BTreeMap::new();
2891 for tool in &decoder.completed_tools {
2892 by_id.insert(tool.id.as_str(), tool);
2893 }
2894 assert_eq!(by_id["call_1"].function.name, "bash");
2895 assert_eq!(by_id["call_1"].function.arguments, r#"{"cmd":"ls"}"#);
2896 assert_eq!(by_id["call_2"].function.name, "read");
2897 assert_eq!(by_id["call_2"].function.arguments, r#"{"path":"a"}"#);
2898 }
2899
2900 #[test]
2901 fn output_item_done_emits_tool_use_event() {
2902 let lines = [
2909 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_abc","name":"bash"}}"#,
2910 "",
2911 r#"data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"{\"command\":\"ls\"}"}"#,
2912 "",
2913 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_abc","name":"bash","arguments":"{\"command\":\"ls\"}"}}"#,
2914 "",
2915 ];
2916 let (_decoder, _text, events) = drive(&lines);
2917
2918 let tool_uses: Vec<_> = events
2919 .iter()
2920 .filter_map(|e| match e {
2921 StreamEvent::Llm(LlmEvent::ToolUse {
2922 tool_name,
2923 tool_id,
2924 input,
2925 }) => Some((tool_name.as_str(), tool_id.as_str(), input.clone())),
2926 _ => None,
2927 })
2928 .collect();
2929 assert_eq!(
2930 tool_uses.len(),
2931 1,
2932 "expected exactly one ToolUse finalize event"
2933 );
2934 assert_eq!(tool_uses[0].0, "bash");
2935 assert_eq!(tool_uses[0].1, "call_abc");
2936 assert_eq!(
2937 tool_uses[0].2,
2938 serde_json::json!({"command": "ls"}),
2939 "input must be parsed as a JSON Value, not a string"
2940 );
2941 }
2942
2943 #[test]
2944 fn parallel_tool_calls_emit_tool_use_per_index() {
2945 let lines = [
2949 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"bash"}}"#,
2950 "",
2951 r#"data: {"type":"response.output_item.added","output_index":1,"item":{"type":"function_call","call_id":"call_2","name":"read"}}"#,
2952 "",
2953 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"bash","arguments":"{\"command\":\"ls\"}"}}"#,
2954 "",
2955 r#"data: {"type":"response.output_item.done","output_index":1,"item":{"type":"function_call","call_id":"call_2","name":"read","arguments":"{\"path\":\"a\"}"}}"#,
2956 "",
2957 ];
2958 let (_decoder, _text, events) = drive(&lines);
2959
2960 let tool_uses: Vec<_> = events
2961 .iter()
2962 .filter_map(|e| match e {
2963 StreamEvent::Llm(LlmEvent::ToolUse {
2964 tool_name,
2965 tool_id,
2966 input,
2967 }) => Some((tool_name.clone(), tool_id.clone(), input.clone())),
2968 _ => None,
2969 })
2970 .collect();
2971
2972 assert_eq!(tool_uses.len(), 2, "one ToolUse finalize per parallel call");
2973 let by_id: std::collections::BTreeMap<&str, &(String, String, serde_json::Value)> =
2974 tool_uses.iter().map(|t| (t.1.as_str(), t)).collect();
2975 assert_eq!(by_id["call_1"].0, "bash");
2976 assert_eq!(by_id["call_1"].2, serde_json::json!({"command": "ls"}));
2977 assert_eq!(by_id["call_2"].0, "read");
2978 assert_eq!(by_id["call_2"].2, serde_json::json!({"path": "a"}));
2979 }
2980
2981 #[test]
2982 fn malformed_arguments_emit_tool_use_with_parse_error() {
2983 let lines = [
2988 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_bad","name":"bash"}}"#,
2989 "",
2990 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_bad","name":"bash","arguments":"{not json"}}"#,
2991 "",
2992 ];
2993 let (_decoder, _text, events) = drive(&lines);
2994
2995 let tool_use = events.iter().find_map(|e| match e {
2996 StreamEvent::Llm(LlmEvent::ToolUse { input, .. }) => Some(input.clone()),
2997 _ => None,
2998 });
2999 let input = tool_use.expect("ToolUse event missing");
3000 assert!(
3001 input.get("__parse_error").and_then(Value::as_str).is_some(),
3002 "malformed arguments must surface __parse_error, got {input}"
3003 );
3004 }
3005
3006 #[test]
3007 fn response_completed_emits_usage_event() {
3008 let lines = [
3009 r#"data: {"type":"response.completed","response":{"usage":{"input_tokens":42,"output_tokens":17}}}"#,
3010 "",
3011 ];
3012 let (_decoder, _text, events) = drive(&lines);
3013 let usage = events.iter().find_map(|e| match e {
3014 StreamEvent::Session(SessionEvent::Usage {
3015 input_tokens,
3016 output_tokens,
3017 ..
3018 }) => Some((*input_tokens, *output_tokens)),
3019 _ => None,
3020 });
3021 assert_eq!(usage, Some((42, 17)));
3022 }
3023
3024 #[test]
3030 fn response_completed_surfaces_cached_tokens_without_double_count() {
3031 let lines = [
3032 r#"data: {"type":"response.completed","response":{"usage":{"input_tokens":1000,"input_tokens_details":{"cached_tokens":900},"output_tokens":17}}}"#,
3033 "",
3034 ];
3035 let (_decoder, _text, events) = drive(&lines);
3036 let usage = events.iter().find_map(|e| match e {
3037 StreamEvent::Session(SessionEvent::Usage {
3038 input_tokens,
3039 output_tokens,
3040 cache_read_input_tokens,
3041 ..
3042 }) => Some((*input_tokens, *output_tokens, *cache_read_input_tokens)),
3043 _ => None,
3044 });
3045 assert_eq!(
3046 usage,
3047 Some((100, 17, 900)),
3048 "input must exclude the cached slice; cache_read must carry it"
3049 );
3050 }
3051
3052 #[test]
3053 fn response_completed_with_zero_usage_emits_nothing() {
3054 let lines = [
3055 r#"data: {"type":"response.output_text.delta","delta":"ok"}"#,
3056 "",
3057 r#"data: {"type":"response.completed","response":{"usage":{"input_tokens":0,"output_tokens":0}}}"#,
3058 "",
3059 ];
3060 let (decoder, _text, events) = drive(&lines);
3061 let any_usage = events
3062 .iter()
3063 .any(|e| matches!(e, StreamEvent::Session(SessionEvent::Usage { .. })));
3064 assert!(!any_usage, "zero-token usage should be suppressed");
3065 assert_eq!(decoder.terminal_result(), Ok(()));
3066 }
3067
3068 #[test]
3069 fn response_failed_is_a_terminal_failure_without_retaining_provider_text() {
3070 let lines = [
3071 r#"data: {"type":"response.failed","response":{"error":{"type":"server_error","message":"ECHOED:secret prompt"}}}"#,
3072 "",
3073 ];
3074 let (decoder, text, events) = drive(&lines);
3075 let failure = decoder.terminal_result().expect_err("must fail");
3076 assert_eq!(failure.code, "responses_capacity");
3078 assert!(failure.is_retryable());
3079 assert_eq!(failure.message, format!("Codex{RESPONSES_CAPACITY_SUFFIX}"));
3080 assert!(!failure.message.contains("ECHOED"));
3081 assert!(text.is_empty());
3082 assert!(events.is_empty());
3083 }
3084
3085 #[test]
3086 fn top_level_error_event_is_a_terminal_failure() {
3087 let lines = [
3088 r#"data: {"type":"error","error":{"type":"server_error","message":"private"}}"#,
3089 "",
3090 ];
3091 let (decoder, _text, _events) = drive(&lines);
3092 assert_eq!(
3093 decoder.terminal_result().expect_err("must fail").code,
3094 "responses_capacity"
3095 );
3096 }
3097
3098 #[test]
3099 fn response_incomplete_is_not_misreported_as_empty_success() {
3100 let lines = [
3101 r#"data: {"type":"response.incomplete","response":{"incomplete_details":{"reason":"max_output_tokens"}}}"#,
3102 "",
3103 ];
3104 let (decoder, _text, _events) = drive(&lines);
3105 let failure = decoder.terminal_result().expect_err("must fail");
3106 assert_eq!(failure.code, "responses_incomplete");
3107 assert!(failure.message.contains("incomplete"));
3108 assert!(!failure.message.contains("max_output_tokens"));
3109 }
3110
3111 #[test]
3112 fn completed_without_text_or_tools_fails_at_transport_boundary() {
3113 let lines = [
3114 r#"data: {"type":"response.completed","response":{"usage":{"input_tokens":7,"output_tokens":0}}}"#,
3115 "",
3116 ];
3117 let (decoder, _text, _events) = drive(&lines);
3118 assert_eq!(
3119 decoder.terminal_result().expect_err("must fail").code,
3120 "responses_empty"
3121 );
3122 }
3123
3124 #[test]
3125 fn eof_without_terminal_event_fails_closed() {
3126 let lines = [
3127 r#"data: {"type":"response.created","response":{"id":"resp_123"}}"#,
3128 "",
3129 ];
3130 let (decoder, _text, _events) = drive(&lines);
3131 assert_eq!(
3132 decoder.terminal_result().expect_err("must fail").code,
3133 "responses_missing_terminal"
3134 );
3135 }
3136
3137 #[test]
3141 fn error_identifier_sanitizer_never_passes_freeform_text() {
3142 assert_eq!(sanitize_error_identifier(None), "absent");
3143 assert_eq!(
3144 sanitize_error_identifier(Some("rate_limit_exceeded")),
3145 "rate_limit_exceeded"
3146 );
3147 assert_eq!(
3148 sanitize_error_identifier(Some("context_length_exceeded")),
3149 "context_length_exceeded"
3150 );
3151 assert_eq!(
3152 sanitize_error_identifier(Some("some_new_code2")),
3153 "some_new_code2"
3154 );
3155 assert_eq!(
3157 sanitize_error_identifier(Some("sk-Abc123XYZ")),
3158 "unlisted_identifier"
3159 );
3160 assert_eq!(
3161 sanitize_error_identifier(Some("Server.Error")),
3162 "unlisted_identifier"
3163 );
3164 assert_eq!(
3165 sanitize_error_identifier(Some("ECHOED: user secret prompt")),
3166 "unsafe_or_freeform"
3167 );
3168 assert_eq!(sanitize_error_identifier(Some("")), "unsafe_or_freeform");
3169 let long = "a".repeat(65);
3170 assert_eq!(sanitize_error_identifier(Some(&long)), "unsafe_or_freeform");
3171 }
3172
3173 #[test]
3177 fn terminal_failure_user_message_is_specific_only_for_known_codes() {
3178 assert!(user_message_for_terminal_failure(
3179 "Codex",
3180 "invalid_request_error",
3181 "context_length_exceeded",
3182 false
3183 )
3184 .contains("/compact"));
3185 assert!(user_message_for_terminal_failure(
3187 "Codex",
3188 "context_length_exceeded",
3189 "absent",
3190 false
3191 )
3192 .contains("context window"));
3193 let default = user_message_for_terminal_failure(
3195 "Codex",
3196 "server_error",
3197 "unlisted_identifier",
3198 false,
3199 );
3200 assert!(default.starts_with("Codex response failed in stream."));
3201 assert!(!default.contains("/compact"));
3202 let capacity = user_message_for_terminal_failure("xAI", "absent", "absent", true);
3204 assert_eq!(
3205 capacity,
3206 "xAI reports the model is at capacity (retries exhausted). Try again in a few minutes or switch models with /model."
3207 );
3208 }
3209
3210 #[test]
3215 fn xai_flat_capacity_error_is_retryable_capacity_failure() {
3216 let lines = [
3217 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}"#,
3218 "",
3219 ];
3220 let (tx, mut rx) = mpsc::unbounded_channel();
3221 let mut decoder = CodexSseDecoder::for_provider("xAI");
3222 let mut text = String::new();
3223 for line in lines {
3224 decoder.push_line(line, &tx, &mut text);
3225 }
3226 decoder.finish();
3227 let failure = decoder.terminal_result().expect_err("must fail");
3228 assert_eq!(failure.code, "responses_capacity");
3229 assert!(failure.is_retryable());
3230 assert_eq!(
3231 failure.message,
3232 "xAI reports the model is at capacity (retries exhausted). Try again in a few minutes or switch models with /model."
3233 );
3234 for banned in ["high demand", "docs.x.ai", "service tier", "Codex"] {
3235 assert!(
3236 !failure.message.contains(banned),
3237 "provider text `{banned}` leaked: {}",
3238 failure.message
3239 );
3240 }
3241 assert!(text.is_empty());
3242 assert!(!decoder.emitted_any_output());
3243 assert!(rx.try_recv().is_err(), "no events for an error-only stream");
3244 }
3245
3246 #[test]
3249 fn flat_code_classifies_capacity_only_for_vetted_codes() {
3250 let capacity = [
3251 r#"data: {"type":"error","code":"rate_limit_exceeded","message":"private"}"#,
3252 "",
3253 ];
3254 let (decoder, _, _) = drive(&capacity);
3255 let failure = decoder.terminal_result().expect_err("must fail");
3256 assert_eq!(failure.code, "responses_capacity");
3257 assert!(failure.is_retryable());
3258
3259 let other = [
3260 r#"data: {"type":"error","code":"invalid_api_key","message":"private"}"#,
3261 "",
3262 ];
3263 let (decoder, _, _) = drive(&other);
3264 let failure = decoder.terminal_result().expect_err("must fail");
3265 assert_eq!(failure.code, "responses_failed");
3266 assert!(!failure.is_retryable());
3267 assert!(!failure.message.contains("private"));
3268 }
3269
3270 #[test]
3271 fn provider_label_is_carried_into_every_terminal_message() {
3272 let (tx, _rx) = mpsc::unbounded_channel();
3273 let mut text = String::new();
3274 let mut decoder = CodexSseDecoder::for_provider("xAI");
3275 decoder.push_line(
3276 r#"data: {"type":"response.completed","response":{"usage":{"input_tokens":1,"output_tokens":0}}}"#,
3277 &tx,
3278 &mut text,
3279 );
3280 decoder.push_line("", &tx, &mut text);
3281 let failure = decoder.terminal_result().expect_err("empty");
3282 assert_eq!(failure.code, "responses_empty");
3283 assert!(failure.is_retryable());
3284 assert_eq!(
3285 failure.message,
3286 "xAI completed without text or tool output. Retry the request."
3287 );
3288
3289 let decoder = CodexSseDecoder::for_provider("xAI");
3290 let failure = decoder.terminal_result().expect_err("missing terminal");
3291 assert_eq!(failure.code, "responses_missing_terminal");
3292 assert!(failure.is_retryable());
3293 assert!(failure.message.starts_with("xAI response stream ended"));
3294
3295 let mut decoder = CodexSseDecoder::for_provider("xAI");
3296 decoder.push_line(
3297 r#"data: {"type":"response.incomplete","response":{"incomplete_details":{"reason":"max_output_tokens"}}}"#,
3298 &tx,
3299 &mut text,
3300 );
3301 decoder.push_line("", &tx, &mut text);
3302 let failure = decoder.terminal_result().expect_err("incomplete");
3303 assert_eq!(failure.code, "responses_incomplete");
3304 assert!(!failure.is_retryable());
3305 assert!(failure.message.starts_with("xAI response was incomplete."));
3306 }
3307
3308 #[test]
3309 fn done_marker_finishes_decoder() {
3310 let lines = [
3311 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_x","name":"bash"}}"#,
3312 "",
3313 r#"data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"{}"}"#,
3314 "",
3315 "data: [DONE]",
3316 "",
3317 ];
3318 let (decoder, _text, _events) = drive(&lines);
3319 assert_eq!(decoder.completed_tools.len(), 1);
3321 assert_eq!(decoder.completed_tools[0].id, "call_x");
3322 assert_eq!(decoder.completed_tools[0].function.arguments, "{}");
3323 }
3324
3325 #[test]
3326 fn finish_is_idempotent_no_double_emit() {
3327 let lines = [
3328 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_y","name":"bash","arguments":"{}"}}"#,
3329 "",
3330 ];
3331 let (mut decoder, _text, _events) = drive(&lines);
3332 assert_eq!(decoder.completed_tools.len(), 1);
3333
3334 decoder.finish();
3336 assert_eq!(
3337 decoder.completed_tools.len(),
3338 1,
3339 "finish() called twice must not double-emit"
3340 );
3341 }
3342
3343 #[test]
3344 fn finish_drains_active_tools_for_state_hygiene() {
3345 let lines = [
3350 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_z","name":"bash"}}"#,
3351 "",
3352 "data: [DONE]",
3353 "",
3354 ];
3355 let (decoder, _text, _events) = drive(&lines);
3356 assert_eq!(decoder.completed_tools.len(), 1);
3357 assert!(
3358 decoder.active_tools.is_empty(),
3359 "active_tools must be drained after finish()"
3360 );
3361 }
3362
3363 #[test]
3364 fn unknown_event_types_are_ignored() {
3365 let lines = [
3366 r#"data: {"type":"response.future_unknown_event","payload":{"x":1}}"#,
3367 "",
3368 r#"data: {"type":"response.output_text.delta","delta":"hi"}"#,
3369 "",
3370 ];
3371 let (_decoder, text_acc, _events) = drive(&lines);
3372 assert_eq!(text_acc, "hi");
3373 }
3374}
3375
3376#[cfg(test)]
3377mod broker_stream_tests {
3378 use super::*;
3384 use crate::auth::{CredentialBroker, LocalBroker};
3385 use std::sync::Arc;
3386
3387 async fn spawn_fake_openai_sse() -> (String, Arc<std::sync::Mutex<String>>) {
3388 use axum::routing::post;
3389 let seen_auth = Arc::new(std::sync::Mutex::new(String::new()));
3390 let seen = seen_auth.clone();
3391 let app = axum::Router::new().route(
3392 "/chat/completions",
3393 post(move |headers: axum::http::HeaderMap| {
3394 let seen = seen.clone();
3395 async move {
3396 *seen.lock().unwrap() = headers
3397 .get("authorization")
3398 .and_then(|v| v.to_str().ok())
3399 .unwrap_or("")
3400 .to_string();
3401 let body = concat!(
3402 "data: {\"choices\":[{\"delta\":{\"content\":\"Hel\"}}]}\n\n",
3403 "data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}\n\n",
3404 "data: [DONE]\n\n",
3405 );
3406 ([("content-type", "text/event-stream")], body)
3407 }
3408 }),
3409 );
3410 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3411 let addr = listener.local_addr().unwrap();
3412 tokio::spawn(async move {
3413 axum::serve(listener, app).await.unwrap();
3414 });
3415 (format!("http://{addr}"), seen_auth)
3416 }
3417
3418 #[tokio::test]
3419 async fn oai_stream_flows_through_broker_and_forwards_deltas() {
3420 let (upstream, seen_auth) = spawn_fake_openai_sse().await;
3421 let broker: Arc<dyn CredentialBroker> = Arc::new(LocalBroker::with_local_base_url(
3422 reqwest::Client::new(),
3423 upstream,
3424 ));
3425 let cfg = ProviderConfig {
3426 base_url: "unused-broker-derives-the-url".to_string(),
3427 model: "test-model".to_string(),
3428 provider: "local".to_string(),
3429 };
3430 let (tx, mut rx) = mpsc::unbounded_channel::<StreamEvent>();
3431 let cancel = tokio_util::sync::CancellationToken::new();
3432
3433 let result = call_oai_stream_inner(
3434 &cfg,
3435 &broker,
3436 &[],
3437 &None,
3438 &[],
3439 &tx,
3440 None,
3441 None,
3442 0,
3443 agent_core::reasoning::ReasoningLevel::Medium,
3444 &cancel,
3445 &crate::runtime::trace::TraceContext::disabled(),
3446 true,
3447 false,
3448 0,
3449 )
3450 .await
3451 .expect("stream must complete");
3452
3453 assert_eq!(result["content"][0]["text"], "Hello");
3455
3456 drop(tx);
3458 let mut streamed = String::new();
3459 while let Ok(ev) = rx.try_recv() {
3460 if let StreamEvent::Llm(crate::runtime::types::LlmEvent::Text(t)) = ev {
3461 streamed.push_str(&t);
3462 }
3463 }
3464 assert_eq!(streamed, "Hello");
3465
3466 assert_eq!(&*seen_auth.lock().unwrap(), "Bearer local");
3468 }
3469
3470 #[tokio::test]
3473 async fn suppressed_sync_route_does_not_enqueue_display_deltas() {
3474 let (upstream, _seen_auth) = spawn_fake_openai_sse().await;
3475 let broker: Arc<dyn CredentialBroker> = Arc::new(LocalBroker::with_local_base_url(
3476 reqwest::Client::new(),
3477 upstream,
3478 ));
3479 let cfg = ProviderConfig {
3480 base_url: "unused-broker-derives-the-url".to_string(),
3481 model: "test-model".to_string(),
3482 provider: "local".to_string(),
3483 };
3484 let (tx, mut rx) = mpsc::unbounded_channel::<StreamEvent>();
3485 let result = call_oai_stream_inner(
3486 &cfg,
3487 &broker,
3488 &[],
3489 &None,
3490 &[],
3491 &tx,
3492 None,
3493 None,
3494 0,
3495 agent_core::reasoning::ReasoningLevel::Medium,
3496 &tokio_util::sync::CancellationToken::new(),
3497 &crate::runtime::trace::TraceContext::disabled(),
3498 true,
3499 true,
3500 0,
3501 )
3502 .await
3503 .expect("stream must complete");
3504 assert_eq!(result["content"][0]["text"], "Hello");
3505 assert!(
3506 rx.try_recv().is_err(),
3507 "sync route must suppress display deltas at production"
3508 );
3509 }
3510
3511 #[tokio::test]
3512 async fn oai_stream_broker_error_fails_closed() {
3513 let broker: Arc<dyn CredentialBroker> = Arc::new(LocalBroker::new(reqwest::Client::new()));
3515 let cfg = ProviderConfig {
3516 base_url: String::new(),
3517 model: "m".to_string(),
3518 provider: "definitely-not-a-provider".to_string(),
3519 };
3520 let (tx, _rx) = mpsc::unbounded_channel::<StreamEvent>();
3521 let cancel = tokio_util::sync::CancellationToken::new();
3522 let err = call_oai_stream_inner(
3523 &cfg,
3524 &broker,
3525 &[],
3526 &None,
3527 &[],
3528 &tx,
3529 None,
3530 None,
3531 0,
3532 agent_core::reasoning::ReasoningLevel::Medium,
3533 &cancel,
3534 &crate::runtime::trace::TraceContext::disabled(),
3535 true,
3536 false,
3537 0,
3538 )
3539 .await
3540 .unwrap_err()
3541 .to_string();
3542 assert!(err.contains("unknown provider"), "got: {err}");
3543 assert!(!err.to_lowercase().contains("bearer"));
3544 }
3545
3546 #[tokio::test]
3551 async fn oai_stream_upstream_error_body_never_surfaces() {
3552 use axum::response::IntoResponse;
3553 const SENTINEL: &str = "PH1-OAI-BROKER-SENTINEL-91d2-RAW";
3554 let app = axum::Router::new().route(
3555 "/chat/completions",
3556 axum::routing::post(move |body: String| async move {
3557 (
3558 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
3559 [("content-type", "application/json")],
3560 format!("{{\"error\":{{\"message\":\"ECHOED:{body}\"}}}}"),
3561 )
3562 .into_response()
3563 }),
3564 );
3565 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3566 let addr = listener.local_addr().unwrap();
3567 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
3568
3569 let broker: Arc<dyn CredentialBroker> = Arc::new(LocalBroker::with_local_base_url(
3570 reqwest::Client::new(),
3571 format!("http://{addr}"),
3572 ));
3573 let cfg = ProviderConfig {
3574 base_url: "unused-broker-derives-the-url".to_string(),
3575 model: "test-model".to_string(),
3576 provider: "local".to_string(),
3577 };
3578 let tools = vec![serde_json::json!({
3579 "name": "ph1_secret_tool_zz",
3580 "description": "internal tool schema",
3581 "input_schema": {"type": "object", "properties": {}}
3582 })];
3583 let msgs: Vec<crate::SharedMessage> = vec![Arc::new(
3584 serde_json::json!({"role": "user", "content": SENTINEL}),
3585 )];
3586 let (tx, _rx) = mpsc::unbounded_channel::<StreamEvent>();
3587 let cancel = tokio_util::sync::CancellationToken::new();
3588
3589 let err = call_oai_stream_inner(
3590 &cfg,
3591 &broker,
3592 &tools,
3593 &Some(format!("system secret {SENTINEL}")),
3594 &msgs,
3595 &tx,
3596 None,
3597 None,
3598 0,
3599 agent_core::reasoning::ReasoningLevel::Medium,
3600 &cancel,
3601 &crate::runtime::trace::TraceContext::disabled(),
3602 true,
3603 false,
3604 0,
3605 )
3606 .await
3607 .expect_err("500 must fail")
3608 .to_string();
3609
3610 assert!(err.contains("500"), "status must survive: {err}");
3611 for banned in ["ECHOED", SENTINEL, "ph1_secret_tool_zz", "system secret"] {
3612 assert!(
3613 !err.contains(banned),
3614 "provider body content `{banned}` leaked into the surfaced error: {err}"
3615 );
3616 }
3617 }
3618}
3619
3620const XAI_PROVIDER_LABEL: &str = "xAI";
3622
3623#[derive(Debug, Clone, Copy)]
3627pub(crate) struct ResponsesProfile {
3628 pub broker_provider: &'static str,
3630 pub path: &'static str,
3632 pub label: &'static str,
3634}
3635
3636pub(crate) const XAI_RESPONSES: ResponsesProfile = ResponsesProfile {
3637 broker_provider: "xai-auth",
3638 path: "/responses",
3639 label: XAI_PROVIDER_LABEL,
3640};
3641
3642pub(crate) const MUSE_RESPONSES: ResponsesProfile = ResponsesProfile {
3644 broker_provider: "muse",
3645 path: "/v1/responses",
3646 label: "Meta Muse",
3647};
3648
3649#[allow(clippy::too_many_arguments)]
3650pub(crate) async fn call_xai_responses_stream_inner(
3651 cfg: &ProviderConfig,
3652 broker: &std::sync::Arc<dyn crate::auth::CredentialBroker>,
3653 tools_schema: &[Value],
3654 system_prompt: &Option<String>,
3655 messages: &[crate::SharedMessage],
3656 tx: &mpsc::UnboundedSender<StreamEvent>,
3657 max_tokens: Option<u32>,
3658 reasoning_level: agent_core::reasoning::ReasoningLevel,
3659 cancel: &tokio_util::sync::CancellationToken,
3660 max_retries: u32,
3661 trace: &crate::runtime::trace::TraceContext,
3662 exact_wire_bytes: bool,
3663) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
3664 let (tools, names) = translate::tools_to_responses(tools_schema);
3665 let input = codex_input_messages(translate::messages_to_oai(messages, system_prompt, &names));
3666 let body = build_xai_body(
3669 &cfg.model,
3670 reasoning_level,
3671 serde_json::to_value(input)?,
3672 tools,
3673 max_tokens,
3674 )
3675 .map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
3676 stream_broker_responses(
3677 XAI_RESPONSES,
3678 cfg,
3679 broker,
3680 body,
3681 &names,
3682 tools_schema,
3683 system_prompt,
3684 messages,
3685 tx,
3686 cancel,
3687 max_retries,
3688 trace,
3689 exact_wire_bytes,
3690 )
3691 .await
3692}
3693
3694#[allow(clippy::too_many_arguments)]
3697pub(crate) async fn call_responses_route_inner(
3698 route_provider: &str,
3699 cfg: &ProviderConfig,
3700 broker: &std::sync::Arc<dyn crate::auth::CredentialBroker>,
3701 tools_schema: &[Value],
3702 system_prompt: &Option<String>,
3703 messages: &[crate::SharedMessage],
3704 tx: &mpsc::UnboundedSender<StreamEvent>,
3705 max_tokens: Option<u32>,
3706 reasoning_level: agent_core::reasoning::ReasoningLevel,
3707 cancel: &tokio_util::sync::CancellationToken,
3708 max_retries: u32,
3709 trace: &crate::runtime::trace::TraceContext,
3710 exact_wire_bytes: bool,
3711) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
3712 if route_provider == MUSE_RESPONSES.broker_provider {
3713 call_muse_responses_stream_inner(
3714 cfg,
3715 broker,
3716 tools_schema,
3717 system_prompt,
3718 messages,
3719 tx,
3720 max_tokens,
3721 reasoning_level,
3722 cancel,
3723 max_retries,
3724 trace,
3725 exact_wire_bytes,
3726 )
3727 .await
3728 } else {
3729 call_xai_responses_stream_inner(
3730 cfg,
3731 broker,
3732 tools_schema,
3733 system_prompt,
3734 messages,
3735 tx,
3736 max_tokens,
3737 reasoning_level,
3738 cancel,
3739 max_retries,
3740 trace,
3741 exact_wire_bytes,
3742 )
3743 .await
3744 }
3745}
3746
3747#[allow(clippy::too_many_arguments)]
3752pub(crate) async fn call_muse_responses_stream_inner(
3753 cfg: &ProviderConfig,
3754 broker: &std::sync::Arc<dyn crate::auth::CredentialBroker>,
3755 tools_schema: &[Value],
3756 system_prompt: &Option<String>,
3757 messages: &[crate::SharedMessage],
3758 tx: &mpsc::UnboundedSender<StreamEvent>,
3759 max_tokens: Option<u32>,
3760 reasoning_level: agent_core::reasoning::ReasoningLevel,
3761 cancel: &tokio_util::sync::CancellationToken,
3762 max_retries: u32,
3763 trace: &crate::runtime::trace::TraceContext,
3764 exact_wire_bytes: bool,
3765) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
3766 let (tools, names) = translate::tools_to_responses(tools_schema);
3767 let instructions = system_prompt.clone().unwrap_or_default();
3768 let input = muse_input_messages(translate::messages_to_oai(messages, &None, &names));
3770 let cache_key = muse_prompt_cache_key(&instructions, input.first());
3771 let body = build_muse_body(
3773 &cfg.model,
3774 reasoning_level,
3775 Value::Array(input),
3776 &instructions,
3777 tools,
3778 max_tokens,
3779 &cache_key,
3780 )
3781 .map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
3782 stream_broker_responses(
3783 MUSE_RESPONSES,
3784 cfg,
3785 broker,
3786 body,
3787 &names,
3788 tools_schema,
3789 system_prompt,
3790 messages,
3791 tx,
3792 cancel,
3793 max_retries,
3794 trace,
3795 exact_wire_bytes,
3796 )
3797 .await
3798}
3799
3800pub(crate) fn muse_prompt_cache_key(instructions: &str, first_item: Option<&Value>) -> String {
3806 codex_prompt_cache_key(instructions, first_item).replacen("synaps-", "synaps-muse-", 1)
3807}
3808
3809#[allow(clippy::too_many_arguments)] pub(crate) fn build_muse_body(
3819 model: &str,
3820 level: agent_core::reasoning::ReasoningLevel,
3821 input: Value,
3822 instructions: &str,
3823 tools: Vec<Value>,
3824 max_tokens: Option<u32>,
3825 prompt_cache_key: &str,
3826) -> Result<Value, String> {
3827 crate::runtime::openai::catalog::validation::validate_reasoning_mutation(
3828 &format!("muse/{model}"),
3829 level,
3830 )?;
3831 let mut body = serde_json::Map::new();
3832 body.insert("model".into(), json!(model));
3833 if !instructions.is_empty() {
3834 body.insert("instructions".into(), json!(instructions));
3835 }
3836 body.insert("input".into(), input);
3837 body.insert("store".into(), json!(false));
3838 body.insert("stream".into(), json!(true));
3839 if !tools.is_empty() {
3840 body.insert("tools".into(), Value::Array(tools));
3841 }
3842 if let Some(max) = max_tokens {
3843 body.insert("max_output_tokens".into(), json!(max));
3844 }
3845 if let Some(effort) = crate::runtime::openai::catalog::muse_effort_label(level) {
3846 body.insert("reasoning".into(), json!({ "effort": effort }));
3847 }
3848 body.insert("prompt_cache_key".into(), json!(prompt_cache_key));
3849 Ok(Value::Object(body))
3850}
3851
3852#[allow(clippy::too_many_arguments)]
3856async fn stream_broker_responses(
3857 profile: ResponsesProfile,
3858 cfg: &ProviderConfig,
3859 broker: &std::sync::Arc<dyn crate::auth::CredentialBroker>,
3860 body: Value,
3861 names: &translate::ToolNameMap,
3862 tools_schema: &[Value],
3863 system_prompt: &Option<String>,
3864 messages: &[crate::SharedMessage],
3865 tx: &mpsc::UnboundedSender<StreamEvent>,
3866 cancel: &tokio_util::sync::CancellationToken,
3867 max_retries: u32,
3868 trace: &crate::runtime::trace::TraceContext,
3869 exact_wire_bytes: bool,
3870) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
3871 let (proxy_request, body_bytes) = crate::auth::ProxyRequest::post_json_exact(
3879 profile.broker_provider,
3880 profile.path,
3881 body,
3882 true,
3883 )?;
3884 let tracer = tr::begin_openai_tracer(
3885 trace,
3886 &cfg.provider,
3887 &cfg.model,
3888 if exact_wire_bytes {
3889 crate::runtime::trace::TransportKind::OpenAiResponses
3890 } else {
3891 crate::runtime::trace::TransportKind::CloudProxy
3892 },
3893 &format!("{}/responses", cfg.base_url.trim_end_matches('/')),
3894 exact_wire_bytes.then_some(body_bytes.as_ref()),
3895 messages,
3896 system_prompt.as_deref(),
3897 tools_schema,
3898 tr::renamed_tool_losses(names),
3899 )
3900 .await;
3901 if let Some(tracer) = &tracer {
3907 tr::capture_request_content(trace, tracer.request_id(), body_bytes.as_ref(), messages);
3908 }
3909 let mut attempt = tr::StreamAttempt::new(tracer);
3910 let mut stream_retry = 0u32;
3911
3912 loop {
3913 if cancel.is_cancelled() {
3914 return Err("request canceled".into());
3915 }
3916 let stream = crate::runtime::api::await_or_cancel(
3917 cancel,
3918 broker.proxy_stream(proxy_request.clone()),
3919 )
3920 .await
3921 .map_err(|_| "request canceled")?;
3922 let stream = match stream {
3925 Err(crate::auth::BrokerError::Refresh(f)) => {
3926 attempt.finish_failed("broker_error", None, None);
3927 return Err(Box::new(super::net::BrokerRefreshError::new(f)));
3928 }
3929 other => other.map_err(|e| super::net::redact_provider_proxy_error(&e.to_string())),
3932 };
3933 let mut stream = match stream {
3934 Ok(stream) => {
3935 attempt.mark_headers();
3936 stream
3937 }
3938 Err(msg) => {
3939 let status = tr::broker_error_status(&msg);
3940 let code =
3941 status.map_or_else(|| "broker_error".to_string(), |s| format!("http_{s}"));
3942 attempt.finish_failed(&code, status, None);
3943 return Err(super::net::normalize_broker_http_error(msg).into());
3944 }
3945 };
3946 let _ = tx.send(StreamEvent::Llm(crate::LlmEvent::ResponseStart));
3947 let mut interrupted = false;
3948 let mut text = String::new();
3949 let mut parser = CodexSseDecoder::for_provider(profile.label);
3950 parser.backfill_message_text = profile.broker_provider == MUSE_RESPONSES.broker_provider;
3951 let mut buf = bytes::BytesMut::new();
3952 while let Some(chunk) = tokio::select! {
3953 biased;
3954 _ = cancel.cancelled() => {
3955 attempt.finish_canceled(None, parser.trace_usage());
3956 return Err("request canceled".into());
3957 }
3958 c = stream.next() => c,
3959 } {
3960 let chunk = match chunk {
3961 Ok(chunk) => {
3962 attempt.mark_first_byte();
3963 chunk
3964 }
3965 Err(e) => {
3966 if !matches!(e, crate::auth::BrokerError::Transport(_)) {
3967 attempt.finish_failed("broker_error", None, None);
3968 return Err(e.into());
3969 }
3970 interrupted = !parser.terminal_success && parser.terminal_failure.is_none();
3971 break;
3972 }
3973 };
3974 buf.extend_from_slice(&chunk);
3975 while let Some(n) = memchr::memchr(b'\n', &buf) {
3976 let line = buf.split_to(n + 1);
3977 parser.push_line(std::str::from_utf8(&line[..n]).unwrap_or(""), tx, &mut text);
3978 }
3979 if parser.saw_model_event {
3980 attempt.mark_first_model_event();
3981 }
3982 }
3983 if !interrupted && !buf.is_empty() {
3984 parser.push_line(std::str::from_utf8(&buf).unwrap_or(""), tx, &mut text);
3985 }
3986 if interrupted || (!parser.terminal_success && parser.terminal_failure.is_none()) {
3987 if stream_retry >= max_retries {
3988 attempt.finish_failed("stream_error", None, None);
3989 return Err(STREAM_INTERRUPTED.into());
3990 }
3991 stream_retry += 1;
3992 attempt.attempt_failed(
3993 crate::runtime::trace::RetryClass::Network,
3994 retry_delay(stream_retry),
3995 None,
3996 None,
3997 "stream_error",
3998 );
3999 wait_stream_retry(tx, cancel, stream_retry, max_retries, false)
4000 .await
4001 .inspect_err(|_| {
4002 attempt.finish_canceled(None, None);
4003 })?;
4004 attempt.restart_clock();
4005 continue;
4006 }
4007 parser.finish();
4008 if parser.saw_model_event {
4012 attempt.mark_first_model_event();
4013 }
4014 if let Err(failure) = parser.terminal_result() {
4018 let output_already_streamed = !text.is_empty() || parser.emitted_any_output();
4024 if failure.is_retryable() && !output_already_streamed && stream_retry < max_retries {
4025 stream_retry += 1;
4026 let delay = retry_delay(stream_retry);
4027 attempt.attempt_failed(
4028 crate::runtime::trace::RetryClass::Other,
4029 delay,
4030 None,
4031 None,
4032 failure.code,
4033 );
4034 tracing::warn!(
4035 code = failure.code,
4036 provider = profile.broker_provider,
4037 "responses stream retry {stream_retry}/{max_retries} after {delay:?}"
4038 );
4039 tokio::select! {
4040 _ = tokio::time::sleep(delay) => {}
4041 _ = cancel.cancelled() => {
4042 attempt.finish_canceled(None, None);
4043 return Err("request canceled".into());
4044 }
4045 }
4046 attempt.restart_clock();
4047 continue;
4048 }
4049 attempt.finish_failed(failure.code, None, None);
4050 return Err(failure.message.into());
4051 }
4052 attempt.finish_success(None, None, None, parser.trace_usage());
4053 let mut content = Vec::new();
4054 if !text.is_empty() {
4055 content.push(json!({"type":"text","text":text}));
4056 }
4057 content.extend(translate::tool_calls_to_content_blocks(
4058 &parser.completed_tools,
4059 names,
4060 ));
4061 let mut response = json!({"role":"assistant","content":content});
4062 if profile.broker_provider == MUSE_RESPONSES.broker_provider {
4063 if let Some(reason) = muse_dangling_preamble(
4064 !parser.completed_tools.is_empty(),
4065 parser.last_message_phase.as_deref(),
4066 parser.last_message_text().unwrap_or(&text),
4067 ) {
4068 tracing::info!(
4069 provider = profile.broker_provider,
4070 reason,
4071 "muse response ended on a preamble without acting"
4072 );
4073 response[DANGLING_PREAMBLE_KEY] = json!(reason);
4076 }
4077 }
4078 return Ok(response);
4079 }
4080}
4081
4082pub(crate) const DANGLING_PREAMBLE_KEY: &str = "synaps_dangling_preamble";
4086
4087pub(crate) const PREAMBLE_MAX_CHARS: usize = 300;
4092
4093pub(crate) fn muse_dangling_preamble(
4104 has_tool_calls: bool,
4105 last_message_phase: Option<&str>,
4106 last_message_text: &str,
4107) -> Option<&'static str> {
4108 if has_tool_calls {
4109 return None;
4110 }
4111 if last_message_phase == Some("commentary") {
4112 return Some("commentary_phase");
4113 }
4114 let text = last_message_text.trim();
4115 if (text.ends_with(':') || text.ends_with('\u{ff1a}'))
4116 && text.chars().count() <= PREAMBLE_MAX_CHARS
4117 {
4118 return Some("colon_preamble");
4119 }
4120 None
4121}
4122
4123pub(crate) const PREAMBLE_NUDGE: &str = "[Host harness note, not a new user request] You ended your turn on a preamble without acting. Continue now: run the tool call you announced, or ask your question in full.";
4127
4128pub(crate) const PREAMBLE_NUDGE_NOTICE: &str = "Muse ended its turn on a preamble without acting; continuing once automatically (disable: SYNAPS_MUSE_PREAMBLE_NUDGE=0).";
4130
4131pub(crate) fn preamble_nudge_enabled() -> bool {
4134 preamble_nudge_enabled_value(std::env::var("SYNAPS_MUSE_PREAMBLE_NUDGE").ok().as_deref())
4135}
4136
4137fn preamble_nudge_enabled_value(value: Option<&str>) -> bool {
4138 !matches!(
4139 value.map(|v| v.trim().to_ascii_lowercase()).as_deref(),
4140 Some("0" | "false" | "off" | "no")
4141 )
4142}
4143
4144pub(crate) fn take_preamble_nudge(
4149 response: &Value,
4150 already_nudged: &mut bool,
4151 cancelled: bool,
4152 enabled: bool,
4153) -> Option<String> {
4154 if *already_nudged || cancelled || !enabled {
4155 return None;
4156 }
4157 let reason = response.get(DANGLING_PREAMBLE_KEY)?.as_str()?.to_string();
4158 *already_nudged = true;
4159 Some(reason)
4160}
4161
4162pub(crate) fn build_xai_body(
4173 model: &str,
4174 level: agent_core::reasoning::ReasoningLevel,
4175 input: Value,
4176 tools: Vec<Value>,
4177 max_tokens: Option<u32>,
4178) -> Result<Value, String> {
4179 use agent_core::reasoning::ReasoningLevel;
4180 crate::runtime::openai::catalog::validation::validate_reasoning_mutation(
4181 &format!("xai-auth/{model}"),
4182 level,
4183 )?;
4184 let mut body = serde_json::Map::new();
4185 body.insert("model".into(), json!(model));
4186 body.insert("input".into(), input);
4187 body.insert("stream".into(), json!(true));
4188 if !tools.is_empty() {
4189 body.insert("tools".into(), Value::Array(tools));
4190 }
4191 if let Some(max) = max_tokens {
4192 body.insert("max_output_tokens".into(), json!(max));
4193 }
4194 match level {
4195 ReasoningLevel::Off | ReasoningLevel::Adaptive => {}
4198 l => {
4199 body.insert("reasoning".into(), json!({ "effort": l.as_str() }));
4200 }
4201 }
4202 Ok(Value::Object(body))
4203}
4204
4205#[cfg(test)]
4206mod xai_tests {
4207 use super::*;
4208 use agent_core::reasoning::ReasoningLevel;
4209
4210 #[test]
4211 fn xai_fixture_is_public_responses_shape() {
4212 let fixture = serde_json::json!({"type":"response.output_text.delta","delta":"hello"});
4213 assert_eq!(fixture["type"], "response.output_text.delta");
4214 assert_eq!(fixture["delta"], "hello");
4215 }
4216
4217 fn body_for(model: &str, level: ReasoningLevel) -> Result<Value, String> {
4218 build_xai_body(model, level, json!([]), Vec::new(), Some(1024))
4219 }
4220
4221 #[test]
4224 fn grok45_emits_exact_documented_efforts() {
4225 for (level, effort) in [
4226 (ReasoningLevel::Low, "low"),
4227 (ReasoningLevel::Medium, "medium"),
4228 (ReasoningLevel::High, "high"),
4229 ] {
4230 let body = body_for("grok-4.5", level).expect("supported effort");
4231 assert_eq!(body["reasoning"], json!({"effort": effort}), "{level}");
4232 assert_eq!(body["model"], "grok-4.5");
4233 assert_eq!(body["stream"], json!(true));
4234 assert_eq!(body["max_output_tokens"], json!(1024));
4235 }
4236 }
4237
4238 #[test]
4239 fn grok47_responses_wire_uses_exact_efforts_and_preserves_tool_shape() {
4240 for (level, effort) in [
4241 (ReasoningLevel::Low, "low"),
4242 (ReasoningLevel::Medium, "medium"),
4243 (ReasoningLevel::High, "high"),
4244 (ReasoningLevel::XHigh, "xhigh"),
4245 ] {
4246 let input = json!([{"role": "user", "content": "hello"}]);
4247 let tool = json!({"type": "function", "name": "inspect", "parameters": {"type": "object"}});
4248 let body = build_xai_body("grok-4.7", level, input.clone(), vec![tool.clone()], Some(1024)).unwrap();
4249 assert_eq!(body["model"], "grok-4.7");
4250 assert_eq!(body["reasoning"], json!({"effort": effort}));
4251 assert_eq!(body["input"], input);
4252 assert_eq!(body["tools"], json!([tool]));
4253 assert_eq!(body["stream"], true);
4254 assert_eq!(body["max_output_tokens"], 1024);
4255 assert!(body.get("messages").is_none());
4256 assert!(body.get("reasoning_effort").is_none());
4257 assert!(body.get("include").is_none());
4260 assert!(body.get("store").is_none());
4261 }
4262 assert!(body_for("grok-4.7", ReasoningLevel::Adaptive).unwrap().get("reasoning").is_none());
4263 for level in [ReasoningLevel::Off, ReasoningLevel::Max, ReasoningLevel::Ultra, ReasoningLevel::UltraCode] {
4264 assert!(body_for("grok-4.7", level).is_err(), "{level}");
4265 }
4266 }
4267
4268 #[test]
4269 fn grok46_emits_exact_documented_efforts() {
4270 for (level, effort) in [
4271 (ReasoningLevel::Low, "low"),
4272 (ReasoningLevel::Medium, "medium"),
4273 (ReasoningLevel::High, "high"),
4274 ] {
4275 let body = body_for("grok-4.6", level).expect("supported effort");
4276 assert_eq!(body["reasoning"], json!({"effort": effort}), "{level}");
4277 assert_eq!(body["model"], "grok-4.6");
4278 assert_eq!(body["stream"], json!(true));
4279 assert_eq!(body["max_output_tokens"], json!(1024));
4280 }
4281 }
4282
4283 #[test]
4284 fn adaptive_omits_reasoning_field_provider_default() {
4285 for model in [
4286 "grok-4.5",
4287 "grok-4.5-latest",
4288 "grok-4.6",
4289 "grok-4.3",
4290 "grok-4.20-0309-non-reasoning",
4291 ] {
4292 let body = body_for(model, ReasoningLevel::Adaptive).expect("adaptive is omission");
4293 assert!(body.get("reasoning").is_none(), "{model}");
4294 }
4295 }
4296
4297 #[test]
4298 fn off_is_rejected_pre_network_when_reasoning_cannot_be_disabled() {
4299 for model in [
4300 "grok-4.5",
4301 "grok-4.5-latest",
4302 "grok-4.6",
4303 "grok-4.20-multi-agent-0309",
4304 "grok-4.3",
4305 ] {
4306 let err = body_for(model, ReasoningLevel::Off).unwrap_err();
4307 assert!(err.contains(model), "{model}: {err}");
4308 }
4309 let body = body_for("grok-4.20-0309-non-reasoning", ReasoningLevel::Off).unwrap();
4311 assert!(body.get("reasoning").is_none());
4312 }
4313
4314 #[test]
4315 fn unsupported_named_efforts_are_rejected_pre_network() {
4316 for level in [
4318 ReasoningLevel::XHigh,
4319 ReasoningLevel::Max,
4320 ReasoningLevel::Ultra,
4321 ] {
4322 assert!(body_for("grok-4.5", level).is_err(), "{level}");
4323 assert!(body_for("grok-4.6", level).is_err(), "{level}");
4324 }
4325 assert!(body_for("grok-4.3", ReasoningLevel::Medium).is_err());
4327 assert!(body_for("grok-4.20-0309-non-reasoning", ReasoningLevel::Medium).is_err());
4329 assert!(body_for("grok-9000", ReasoningLevel::Medium).is_err());
4331 }
4332
4333 #[test]
4334 fn multi_agent_xhigh_is_exact_agent_count_control() {
4335 let body = body_for("grok-4.20-multi-agent-0309", ReasoningLevel::XHigh).unwrap();
4336 assert_eq!(body["reasoning"], json!({"effort": "xhigh"}));
4337 }
4338
4339 #[test]
4340 fn responses_wire_shape_tools_flat_and_no_chat_completions_fields() {
4341 let tools = vec![json!({
4342 "type": "function",
4343 "name": "get_weather",
4344 "description": "d",
4345 "parameters": {"type": "object"}
4346 })];
4347 let body =
4348 build_xai_body("grok-4.5", ReasoningLevel::High, json!([]), tools, None).unwrap();
4349 assert_eq!(body["tools"][0]["name"], "get_weather");
4352 assert!(body["tools"][0].get("function").is_none());
4353 assert!(body.get("input").is_some());
4354 assert!(body.get("messages").is_none());
4355 assert!(body.get("max_output_tokens").is_none());
4356 }
4357}
4358
4359#[cfg(test)]
4360mod xai_capacity_retry_tests {
4361 use super::*;
4370 use agent_core::auth::{
4371 AccessToken, BrokerError, ProxyByteStream, ProxyRequest, ProxyResponse,
4372 };
4373 use async_trait::async_trait;
4374 use std::sync::{
4375 atomic::{AtomicUsize, Ordering},
4376 Arc,
4377 };
4378
4379 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";
4382
4383 const XAI_SUCCESS_SSE: &str = concat!(
4384 "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\"}}\n\n",
4385 "data: {\"type\":\"response.output_text.delta\",\"delta\":\"GROK_OK\"}\n\n",
4386 "data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":7,\"output_tokens\":2}}}\n\n",
4387 );
4388
4389 struct ScriptedXaiBroker {
4392 bodies: Vec<&'static str>,
4393 calls: Arc<AtomicUsize>,
4394 seen: Arc<std::sync::Mutex<Vec<ProxyRequest>>>,
4395 }
4396
4397 #[async_trait]
4398 impl crate::auth::CredentialBroker for ScriptedXaiBroker {
4399 async fn access_token(
4400 &self,
4401 _p: agent_core::auth::OAuthProviderId,
4402 ) -> Result<AccessToken, BrokerError> {
4403 Err(BrokerError::Denied(
4404 "xai route must use proxy_stream".into(),
4405 ))
4406 }
4407 async fn proxy(&self, _request: ProxyRequest) -> Result<ProxyResponse, BrokerError> {
4408 Err(BrokerError::Denied("not implemented in stub".into()))
4409 }
4410 async fn proxy_stream(
4411 &self,
4412 request: ProxyRequest,
4413 ) -> Result<ProxyByteStream, BrokerError> {
4414 request.validate()?;
4415 let n = self.calls.fetch_add(1, Ordering::SeqCst);
4416 self.seen.lock().unwrap().push(request);
4417 let body = self.bodies[n.min(self.bodies.len() - 1)];
4418 Ok(Box::pin(futures::stream::iter(vec![Ok(
4419 bytes::Bytes::from(body),
4420 )])))
4421 }
4422 async fn anthropic_usage(&self) -> Result<serde_json::Value, BrokerError> {
4423 Err(BrokerError::Denied("not implemented in stub".into()))
4424 }
4425 async fn capabilities(&self) -> Result<Vec<agent_core::auth::ProviderStatus>, BrokerError> {
4426 Ok(vec![])
4427 }
4428 }
4429
4430 struct RefreshFailingBroker(agent_core::auth::RefreshFailureKind);
4433
4434 #[async_trait]
4435 impl crate::auth::CredentialBroker for RefreshFailingBroker {
4436 async fn access_token(
4437 &self,
4438 _p: agent_core::auth::OAuthProviderId,
4439 ) -> Result<AccessToken, BrokerError> {
4440 Err(BrokerError::Denied("stub".into()))
4441 }
4442 async fn proxy(&self, _request: ProxyRequest) -> Result<ProxyResponse, BrokerError> {
4443 Err(BrokerError::Denied("stub".into()))
4444 }
4445 async fn proxy_stream(&self, _r: ProxyRequest) -> Result<ProxyByteStream, BrokerError> {
4446 Err(BrokerError::Refresh(agent_core::auth::RefreshFailure::new(
4447 self.0,
4448 "transport error",
4449 )))
4450 }
4451 async fn anthropic_usage(&self) -> Result<serde_json::Value, BrokerError> {
4452 Err(BrokerError::Denied("stub".into()))
4453 }
4454 async fn capabilities(&self) -> Result<Vec<agent_core::auth::ProviderStatus>, BrokerError> {
4455 Ok(vec![])
4456 }
4457 }
4458
4459 #[tokio::test]
4464 async fn broker_refresh_failure_kind_survives_the_stream_pipeline() {
4465 use agent_core::auth::RefreshFailureKind as K;
4466 for (kind, api) in [
4467 (K::Transient, true),
4468 (K::LockBusy, true),
4469 (K::Other, true),
4470 (K::ActionRequired, true),
4471 (K::Credential, false),
4472 ] {
4473 let broker: Arc<dyn crate::auth::CredentialBroker> =
4474 Arc::new(RefreshFailingBroker(kind));
4475 let cfg = ProviderConfig {
4476 base_url: "https://api.meta.ai/v1".to_string(),
4477 model: "muse-x".to_string(),
4478 provider: "muse".to_string(),
4479 };
4480 let (tx, _rx) = mpsc::unbounded_channel::<StreamEvent>();
4481 let err = call_xai_responses_stream_inner(
4482 &cfg,
4483 &broker,
4484 &[],
4485 &None,
4486 &[],
4487 &tx,
4488 None,
4489 agent_core::reasoning::ReasoningLevel::Adaptive,
4490 &tokio_util::sync::CancellationToken::new(),
4491 0,
4492 &crate::runtime::trace::TraceContext::disabled(),
4493 true,
4494 )
4495 .await
4496 .unwrap_err();
4497 let runtime = super::super::net::provider_error_to_runtime_for("muse/muse-x", err);
4498 match (&runtime, api) {
4499 (crate::RuntimeError::ApiStatus(m), true) => {
4500 assert!(m.contains("credential refresh failed"), "{kind:?}: {m}");
4501 assert!(!m.contains("Sign in again"), "{kind:?}: {m}");
4502 }
4503 (crate::RuntimeError::Config(_), false) => {}
4504 other => panic!("{kind:?}: {other:?}"),
4505 }
4506 }
4507 }
4508
4509 struct Run {
4510 result: Result<Value, Box<dyn std::error::Error + Send + Sync>>,
4511 calls: usize,
4512 seen: Vec<ProxyRequest>,
4513 events: Vec<StreamEvent>,
4514 }
4515
4516 async fn run_xai(bodies: Vec<&'static str>, max_retries: u32) -> Run {
4517 let calls = Arc::new(AtomicUsize::new(0));
4518 let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
4519 let broker: Arc<dyn crate::auth::CredentialBroker> = Arc::new(ScriptedXaiBroker {
4520 bodies,
4521 calls: Arc::clone(&calls),
4522 seen: Arc::clone(&seen),
4523 });
4524 let cfg = ProviderConfig {
4525 base_url: "https://api.x.ai/v1".to_string(),
4526 model: "grok-4.5".to_string(),
4527 provider: "xai-auth".to_string(),
4528 };
4529 let (tx, mut rx) = mpsc::unbounded_channel::<StreamEvent>();
4530 let result = call_xai_responses_stream_inner(
4531 &cfg,
4532 &broker,
4533 &[],
4534 &Some("system".to_string()),
4535 &[],
4536 &tx,
4537 None,
4538 agent_core::reasoning::ReasoningLevel::Adaptive,
4539 &tokio_util::sync::CancellationToken::new(),
4540 max_retries,
4541 &crate::runtime::trace::TraceContext::disabled(),
4542 true,
4543 )
4544 .await;
4545 drop(tx);
4546 let mut events = Vec::new();
4547 while let Ok(event) = rx.try_recv() {
4548 events.push(event);
4549 }
4550 let seen = std::mem::take(&mut *seen.lock().unwrap());
4551 Run {
4552 result,
4553 calls: calls.load(Ordering::SeqCst),
4554 seen,
4555 events,
4556 }
4557 }
4558
4559 fn text_events(events: &[StreamEvent]) -> Vec<String> {
4560 events
4561 .iter()
4562 .filter_map(|e| match e {
4563 StreamEvent::Llm(crate::runtime::types::LlmEvent::Text(t)) => Some(t.clone()),
4564 _ => None,
4565 })
4566 .collect()
4567 }
4568
4569 #[tokio::test(start_paused = true)]
4570 async fn xai_capacity_error_is_retried_with_identical_bytes_then_succeeds() {
4571 let run = run_xai(vec![XAI_CAPACITY_SSE, XAI_SUCCESS_SSE], 3).await;
4572 let value = run.result.expect("second attempt must succeed");
4573 assert_eq!(value["content"][0]["text"], "GROK_OK");
4574 assert_eq!(run.calls, 2, "exactly one retry");
4575 assert_eq!(run.seen.len(), 2);
4576 assert_eq!(
4577 run.seen[0].body_bytes, run.seen[1].body_bytes,
4578 "retry must re-send the identical serialized body"
4579 );
4580 assert_eq!(run.seen[0].body, run.seen[1].body);
4581 assert_eq!(run.seen[0].provider, "xai-auth");
4582 assert_eq!(run.seen[0].path, "/responses");
4583 assert_eq!(text_events(&run.events), vec!["GROK_OK".to_string()]);
4585 }
4586
4587 #[tokio::test(start_paused = true)]
4588 async fn xai_capacity_error_with_zero_retries_surfaces_xai_capacity_message() {
4589 let run = run_xai(vec![XAI_CAPACITY_SSE], 0).await;
4590 let err = run.result.expect_err("no retry budget → terminal");
4591 let msg = err.to_string();
4592 assert_eq!(
4593 msg,
4594 "xAI reports the model is at capacity (retries exhausted). Try again in a few minutes or switch models with /model."
4595 );
4596 assert_eq!(run.calls, 1);
4597 for banned in ["Codex", "high demand", "docs.x.ai", "service tier"] {
4598 assert!(!msg.contains(banned), "`{banned}` leaked: {msg}");
4599 }
4600 assert!(matches!(
4602 super::super::net::provider_error_to_runtime(err),
4603 crate::RuntimeError::ApiStatus(_)
4604 ));
4605 }
4606
4607 #[tokio::test(start_paused = true)]
4608 async fn xai_capacity_error_exhausting_budget_counts_every_attempt() {
4609 let run = run_xai(vec![XAI_CAPACITY_SSE], 2).await;
4610 let err = run.result.expect_err("persistent capacity → terminal");
4611 assert!(err
4612 .to_string()
4613 .starts_with("xAI reports the model is at capacity"));
4614 assert_eq!(run.calls, 3, "initial attempt + 2 retries");
4615 assert!(text_events(&run.events).is_empty());
4616 }
4617
4618 #[tokio::test(start_paused = true)]
4621 async fn xai_does_not_retry_after_partial_text_was_streamed() {
4622 const PARTIAL_THEN_ERROR: &str = concat!(
4623 "data: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\n\n",
4624 "data: {\"type\":\"error\",\"code\":null,\"message\":\"at capacity\"}\n\n",
4625 );
4626 let run = run_xai(vec![PARTIAL_THEN_ERROR, XAI_SUCCESS_SSE], 3).await;
4627 let err = run.result.expect_err("must not retry after partial output");
4628 assert!(err
4629 .to_string()
4630 .starts_with("xAI reports the model is at capacity"));
4631 assert_eq!(run.calls, 1, "no retry once output reached the UI");
4632 assert_eq!(text_events(&run.events), vec!["partial".to_string()]);
4633 }
4634
4635 #[tokio::test(start_paused = true)]
4637 async fn xai_retries_eof_after_tool_preview_with_reset() {
4638 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";
4639 let run = run_xai(vec![TOOL_THEN_EOF, XAI_SUCCESS_SSE], 3).await;
4640 assert_eq!(run.result.unwrap()["content"][0]["text"], "GROK_OK");
4641 assert_eq!(run.calls, 2);
4642 assert_eq!(run.seen[0].body_bytes, run.seen[1].body_bytes);
4643 assert!(run
4644 .events
4645 .iter()
4646 .any(|e| matches!(e, StreamEvent::Llm(crate::LlmEvent::ResponseReset))));
4647 }
4648
4649 #[test]
4650 fn responses_exact_account_errors_survive_decoder_net_and_driver() {
4651 for (code, suffix, expected) in [
4652 ("invalid_api_key", RESPONSES_AUTH_SUFFIX, "auth"),
4653 ("authentication_error", RESPONSES_AUTH_SUFFIX, "auth"),
4654 (
4655 "invalid_authentication_error",
4656 RESPONSES_AUTH_SUFFIX,
4657 "auth",
4658 ),
4659 ("insufficient_quota", RESPONSES_QUOTA_SUFFIX, "quota"),
4660 ("quota_exceeded", RESPONSES_QUOTA_SUFFIX, "quota"),
4661 ("billing_error", RESPONSES_QUOTA_SUFFIX, "quota"),
4662 ] {
4663 for label in ["Codex", "OpenAI", "xAI"] {
4664 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4665 let mut decoder = CodexSseDecoder::for_provider(label);
4666 let frame = format!(
4667 "data: {}\n\n",
4668 json!({"type":"response.failed", "response":{
4669 "error":{"code":code,"message":"SECRET at capacity"}
4670 }})
4671 );
4672 let mut text = String::new();
4673 for line in frame.lines() {
4674 decoder.push_line(line, &tx, &mut text);
4675 }
4676 let failure = decoder.terminal_result().unwrap_err();
4677 assert_eq!(failure.message, format!("{label}{suffix}"));
4678 assert!(!failure.is_retryable());
4679 let runtime = super::super::net::provider_error_to_runtime(failure.message.into());
4680 assert!(matches!(runtime, crate::RuntimeError::ApiStatus(_)));
4681 let typed = crate::runtime::helpers::turn_error_for(&runtime, "test");
4682 assert_eq!(
4683 crate::extensions::session_driver::classify_turn_error(&typed),
4684 (
4685 crate::extensions::session_driver::Outcome::ProviderError,
4686 expected.into()
4687 )
4688 );
4689 assert!(!typed.message.contains("SECRET"));
4690 }
4691 }
4692 for code in [
4693 "permission_denied",
4694 "context_length_exceeded",
4695 "invalid_request_error",
4696 "unknown",
4697 ] {
4698 let message = user_message_for_terminal_failure("Codex", "absent", code, false);
4699 assert_eq!(
4700 crate::extensions::session_driver::classify_error(&message).0,
4701 crate::extensions::session_driver::Outcome::Blocked
4702 );
4703 }
4704 }
4705
4706 #[test]
4707 fn explicit_policy_and_conflicting_error_classes_outrank_capacity_prose() {
4708 for (kind, code) in [
4709 ("absent", "permission_denied"),
4710 ("permission_error", "rate_limit_exceeded"),
4711 ("invalid_request_error", "insufficient_quota"),
4712 ("authentication_error", "quota_exceeded"),
4713 ("server_error", "invalid_api_key"),
4714 ("unknown", "absent"),
4715 ] {
4716 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4717 let mut decoder = CodexSseDecoder::for_provider("Codex");
4718 let frame = format!(
4719 "data: {}",
4720 json!({"type":"response.failed", "response":{
4721 "error":{"type":kind, "code":code, "message":"private policy at capacity"}
4722 }})
4723 );
4724 let mut text = String::new();
4725 decoder.push_line(&frame, &tx, &mut text);
4726 decoder.push_line("", &tx, &mut text);
4727 let failure = decoder.terminal_result().unwrap_err();
4728 assert!(!failure.is_retryable());
4729 assert_eq!(
4730 crate::extensions::session_driver::classify_error(&failure.message).0,
4731 crate::extensions::session_driver::Outcome::Blocked
4732 );
4733 assert!(!failure.message.contains("private"));
4734 }
4735 }
4736
4737 #[tokio::test(start_paused = true)]
4740 async fn xai_non_capacity_error_is_terminal_and_labelled_xai() {
4741 const OTHER_ERROR: &str = "data: {\"type\":\"error\",\"code\":\"invalid_api_key\",\"message\":\"ECHOED:secret prompt\"}\n\n";
4742 let run = run_xai(vec![OTHER_ERROR, XAI_SUCCESS_SSE], 3).await;
4743 let err = run.result.expect_err("must fail");
4744 let msg = err.to_string();
4745 assert_eq!(msg, format!("xAI{RESPONSES_AUTH_SUFFIX}"));
4746 assert!(!msg.contains("ECHOED"));
4747 assert_eq!(run.calls, 1);
4748 }
4749}
4750
4751#[cfg(test)]
4754mod codex_wire_tests {
4755 use crate::runtime::openai::catalog::{
4756 plan_codex_execution, CodexMultiAgentMode, CodexRequestRole,
4757 };
4758 use agent_core::reasoning::ReasoningLevel;
4759
4760 fn plan(
4761 level: ReasoningLevel,
4762 role: CodexRequestRole,
4763 ) -> crate::runtime::openai::catalog::CodexExecutionPlan {
4764 plan_codex_execution("openai-codex/gpt-5.6-sol", level, role, None)
4765 .expect("Sol request plan")
4766 }
4767
4768 #[allow(clippy::too_many_arguments)]
4769 fn body_for_level(
4770 model: &str,
4771 level: ReasoningLevel,
4772 input: serde_json::Value,
4773 instructions: String,
4774 tools: Vec<serde_json::Value>,
4775 temperature: Option<f32>,
4776 max_tokens: Option<u32>,
4777 ) -> serde_json::Value {
4778 let qualified = format!("openai-codex/{model}");
4779 let plan = plan_codex_execution(&qualified, level, CodexRequestRole::Foreground, None)
4780 .expect("request plan");
4781 super::build_codex_body(
4782 model,
4783 &plan,
4784 input,
4785 instructions,
4786 tools,
4787 temperature,
4788 max_tokens,
4789 "synaps-test-cache-key",
4790 )
4791 }
4792
4793 fn codex_effort_for(model_id: &str, level: ReasoningLevel) -> Result<String, String> {
4795 let qualified = format!("openai-codex/{model_id}");
4796 let plan = plan_codex_execution(&qualified, level, CodexRequestRole::Foreground, None)
4797 .map_err(|error| error.to_string())?;
4798 plan.wire_effort
4799 .map(|effort| effort.as_str().to_string())
4800 .ok_or_else(|| "reasoning effort omitted".to_string())
4801 }
4802
4803 #[test]
4804 fn sol_lowers_ultra_to_max_for_requests() {
4805 let effort = codex_effort_for("gpt-5.6-sol", ReasoningLevel::Ultra).unwrap();
4806 assert_eq!(effort, "max");
4807 }
4808
4809 #[test]
4810 fn sol_emits_max_exact() {
4811 let effort = codex_effort_for("gpt-5.6-sol", ReasoningLevel::Max).unwrap();
4812 assert_eq!(effort, "max");
4813 }
4814
4815 #[test]
4816 fn sol_emits_xhigh_exact() {
4817 let effort = codex_effort_for("gpt-5.6-sol", ReasoningLevel::XHigh).unwrap();
4818 assert_eq!(effort, "xhigh");
4819 }
4820
4821 #[test]
4822 fn luna_emits_max_exact() {
4823 let effort = codex_effort_for("gpt-5.6-luna", ReasoningLevel::Max).unwrap();
4824 assert_eq!(effort, "max");
4825 }
4826
4827 #[test]
4828 fn luna_rejects_ultra_before_network() {
4829 let err = codex_effort_for("gpt-5.6-luna", ReasoningLevel::Ultra).unwrap_err();
4831 assert!(err.contains("ultra"), "{err}");
4832 assert!(err.contains("gpt-5.6-luna"), "{err}");
4833 }
4834
4835 #[test]
4836 fn gpt55_emits_xhigh_exact() {
4837 let effort = codex_effort_for("gpt-5.5", ReasoningLevel::XHigh).unwrap();
4838 assert_eq!(effort, "xhigh");
4839 }
4840
4841 #[test]
4842 fn gpt55_rejects_max_before_network() {
4843 assert!(codex_effort_for("gpt-5.5", ReasoningLevel::Max).is_err());
4844 assert!(codex_effort_for("gpt-5.5", ReasoningLevel::Ultra).is_err());
4845 }
4846
4847 #[test]
4848 fn off_and_adaptive_omit_reasoning_field() {
4849 for level in [ReasoningLevel::Off, ReasoningLevel::Adaptive] {
4851 let body = body_for_level(
4852 "gpt-5.6-sol",
4853 level,
4854 serde_json::json!([]),
4855 "sys".to_string(),
4856 vec![],
4857 None,
4858 None,
4859 );
4860 assert!(
4861 body.get("reasoning").is_none(),
4862 "{level:?} must not emit reasoning field, body={body}"
4863 );
4864 }
4865 }
4866
4867 #[test]
4868 fn max_as_str_is_not_xhigh() {
4869 assert_ne!(ReasoningLevel::Max.as_str(), ReasoningLevel::XHigh.as_str());
4870 assert_eq!(ReasoningLevel::Max.as_str(), "max");
4871 }
4872
4873 #[test]
4874 fn ultra_mode_context_is_exact_proactive_developer_item() {
4875 let plan = plan(ReasoningLevel::Ultra, CodexRequestRole::Foreground);
4876 assert_eq!(plan.multi_agent_mode, Some(CodexMultiAgentMode::Proactive));
4877 let item = super::codex_multi_agent_mode_item(&plan).expect("Ultra context item");
4878 assert_eq!(
4879 item.get("type").and_then(serde_json::Value::as_str),
4880 Some("message")
4881 );
4882 assert_eq!(
4883 item.get("role").and_then(serde_json::Value::as_str),
4884 Some("developer")
4885 );
4886 assert_eq!(
4887 item.pointer("/content/0/text")
4888 .and_then(serde_json::Value::as_str),
4889 Some(concat!(
4890 "<multi_agent_mode>",
4891 "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.",
4892 "</multi_agent_mode>"
4893 ))
4894 );
4895 }
4896
4897 #[test]
4898 fn max_and_xhigh_mode_context_is_exact_explicit_only_item() {
4899 for level in [ReasoningLevel::Max, ReasoningLevel::XHigh] {
4900 let plan = plan(level, CodexRequestRole::Foreground);
4901 assert_eq!(
4902 plan.multi_agent_mode,
4903 Some(CodexMultiAgentMode::ExplicitRequestOnly)
4904 );
4905 let item = super::codex_multi_agent_mode_item(&plan).expect("v2 context item");
4906 assert_eq!(
4907 item.pointer("/content/0/text")
4908 .and_then(serde_json::Value::as_str),
4909 Some(concat!(
4910 "<multi_agent_mode>",
4911 "Do not spawn sub-agents unless the user or applicable AGENTS.md/skill instructions explicitly ask for sub-agents, delegation, or parallel agent work.",
4912 "</multi_agent_mode>"
4913 )),
4914 "{level:?}"
4915 );
4916 }
4917 }
4918
4919 #[test]
4920 fn mode_context_is_inserted_once_at_stable_head_position() {
4921 let plan = plan(ReasoningLevel::Ultra, CodexRequestRole::Foreground);
4922 let mut input = vec![
4923 serde_json::json!({"role":"user","content":"first"}),
4924 serde_json::json!({"role":"assistant","content":"answer"}),
4925 serde_json::json!({"role":"user","content":"current"}),
4926 ];
4927 super::insert_codex_multi_agent_mode(&mut input, &plan);
4928 let mode_positions = input
4929 .iter()
4930 .enumerate()
4931 .filter(|(_, item)| {
4932 item.pointer("/content/0/text")
4933 .and_then(serde_json::Value::as_str)
4934 .is_some_and(|text| text.starts_with("<multi_agent_mode>"))
4935 })
4936 .map(|(index, _)| index)
4937 .collect::<Vec<_>>();
4938 assert_eq!(mode_positions, vec![0]);
4939 assert_eq!(
4940 input[3].get("content").and_then(serde_json::Value::as_str),
4941 Some("current")
4942 );
4943 }
4944
4945 #[test]
4951 fn mode_context_placement_preserves_prompt_prefix_across_turns() {
4952 let plan = plan(ReasoningLevel::Ultra, CodexRequestRole::Foreground);
4953 let turn1 = vec![
4954 serde_json::json!({"role":"user","content":"first"}),
4955 serde_json::json!({"role":"assistant","content":"answer"}),
4956 ];
4957 let mut turn2 = turn1.clone();
4958 turn2.push(serde_json::json!({"role":"user","content":"follow-up"}));
4959 let mut input1 = turn1;
4960 let mut input2 = turn2;
4961 super::insert_codex_multi_agent_mode(&mut input1, &plan);
4962 super::insert_codex_multi_agent_mode(&mut input2, &plan);
4963 assert_eq!(
4964 &input2[..input1.len()],
4965 &input1[..],
4966 "turn N's input must be a strict prefix of turn N+1's input"
4967 );
4968 }
4969
4970 #[test]
4971 fn prompt_cache_key_is_stable_for_the_same_conversation_head() {
4972 let first = serde_json::json!({"role":"user","content":"hello"});
4973 let a = super::codex_prompt_cache_key("sys", Some(&first));
4974 let b = super::codex_prompt_cache_key("sys", Some(&first));
4975 assert_eq!(a, b);
4976 assert!(a.starts_with("synaps-"), "{a}");
4977 }
4978
4979 #[test]
4980 fn prompt_cache_key_differs_across_conversations() {
4981 let first = serde_json::json!({"role":"user","content":"hello"});
4982 let other = serde_json::json!({"role":"user","content":"different task"});
4983 assert_ne!(
4984 super::codex_prompt_cache_key("sys", Some(&first)),
4985 super::codex_prompt_cache_key("sys", Some(&other)),
4986 );
4987 assert_ne!(
4988 super::codex_prompt_cache_key("sys", Some(&first)),
4989 super::codex_prompt_cache_key("other-sys", Some(&first)),
4990 );
4991 }
4992
4993 #[test]
4994 fn body_carries_prompt_cache_key() {
4995 let body = body_for_level(
4996 "gpt-5.6-sol",
4997 ReasoningLevel::Medium,
4998 serde_json::json!([]),
4999 "sys".to_string(),
5000 vec![],
5001 None,
5002 None,
5003 );
5004 assert_eq!(
5005 body.get("prompt_cache_key")
5006 .and_then(serde_json::Value::as_str),
5007 Some("synaps-test-cache-key")
5008 );
5009 }
5010
5011 #[test]
5012 fn worker_ultra_has_no_mode_context_item() {
5013 let plan = plan(ReasoningLevel::Ultra, CodexRequestRole::Worker);
5014 assert!(super::codex_multi_agent_mode_item(&plan).is_none());
5015 }
5016
5017 #[test]
5020 fn body_lowers_ultra_reasoning_effort_to_max() {
5021 let body = body_for_level(
5022 "gpt-5.6-sol",
5023 ReasoningLevel::Ultra,
5024 serde_json::json!([]),
5025 "sys".to_string(),
5026 vec![],
5027 None,
5028 None,
5029 );
5030 let effort = body
5031 .get("reasoning")
5032 .and_then(|r| r.get("effort"))
5033 .and_then(serde_json::Value::as_str)
5034 .expect("reasoning.effort must be present for Ultra");
5035 assert_eq!(effort, "max");
5036 }
5037
5038 #[test]
5039 fn body_contains_reasoning_effort_for_max() {
5040 let body = body_for_level(
5041 "gpt-5.6-sol",
5042 ReasoningLevel::Max,
5043 serde_json::json!([]),
5044 "sys".to_string(),
5045 vec![],
5046 None,
5047 None,
5048 );
5049 let effort = body
5050 .get("reasoning")
5051 .and_then(|r| r.get("effort"))
5052 .and_then(serde_json::Value::as_str)
5053 .expect("reasoning.effort must be present for Max");
5054 assert_eq!(effort, "max");
5055 assert_ne!(effort, "xhigh");
5057 }
5058
5059 #[test]
5060 fn body_contains_reasoning_effort_for_xhigh() {
5061 let body = body_for_level(
5062 "gpt-5.6-sol",
5063 ReasoningLevel::XHigh,
5064 serde_json::json!([]),
5065 "sys".to_string(),
5066 vec![],
5067 None,
5068 None,
5069 );
5070 let effort = body
5071 .get("reasoning")
5072 .and_then(|r| r.get("effort"))
5073 .and_then(serde_json::Value::as_str)
5074 .expect("reasoning.effort must be present for XHigh");
5075 assert_eq!(effort, "xhigh");
5076 }
5077
5078 #[test]
5079 fn body_contains_reasoning_effort_for_low_medium_high() {
5080 for (level, expected) in [
5081 (ReasoningLevel::Low, "low"),
5082 (ReasoningLevel::Medium, "medium"),
5083 (ReasoningLevel::High, "high"),
5084 ] {
5085 let body = body_for_level(
5086 "gpt-5.6-sol",
5087 level,
5088 serde_json::json!([]),
5089 "sys".to_string(),
5090 vec![],
5091 None,
5092 None,
5093 );
5094 let effort = body
5095 .get("reasoning")
5096 .and_then(|r| r.get("effort"))
5097 .and_then(serde_json::Value::as_str)
5098 .unwrap_or_else(|| panic!("{level:?} must emit reasoning.effort"));
5099 assert_eq!(effort, expected, "{level:?}");
5100 }
5101 }
5102
5103 #[test]
5104 fn body_always_sets_model_and_stream_and_include() {
5105 let body = body_for_level(
5106 "gpt-5.5",
5107 ReasoningLevel::XHigh,
5108 serde_json::json!([]),
5109 "instructions".to_string(),
5110 vec![],
5111 None,
5112 None,
5113 );
5114 assert_eq!(
5115 body.get("model").and_then(serde_json::Value::as_str),
5116 Some("gpt-5.5")
5117 );
5118 assert_eq!(
5119 body.get("stream").and_then(serde_json::Value::as_bool),
5120 Some(true)
5121 );
5122 let includes = body.get("include").expect("include must be present");
5123 assert!(
5124 includes.as_array().is_some_and(|a| {
5125 a.iter()
5126 .any(|v| v.as_str() == Some("reasoning.encrypted_content"))
5127 }),
5128 "include must contain reasoning.encrypted_content"
5129 );
5130 assert_eq!(
5131 body.get("store").and_then(serde_json::Value::as_bool),
5132 Some(false)
5133 );
5134 }
5135
5136 #[test]
5137 fn body_omits_temperature_and_max_tokens_when_none() {
5138 let body = body_for_level(
5139 "gpt-5.6-sol",
5140 ReasoningLevel::Medium,
5141 serde_json::json!([]),
5142 "sys".to_string(),
5143 vec![],
5144 None,
5145 None,
5146 );
5147 assert!(body.get("temperature").is_none());
5148 assert!(body.get("max_output_tokens").is_none());
5149 }
5150
5151 #[test]
5152 fn body_includes_temperature_and_max_tokens_when_some() {
5153 let body = body_for_level(
5154 "gpt-5.6-sol",
5155 ReasoningLevel::High,
5156 serde_json::json!([]),
5157 "sys".to_string(),
5158 vec![],
5159 Some(0.7),
5160 Some(2048),
5161 );
5162 let temp = body
5163 .get("temperature")
5164 .and_then(serde_json::Value::as_f64)
5165 .unwrap();
5166 assert!((temp - 0.7f64).abs() < 0.01, "temperature={temp}");
5167 assert_eq!(
5168 body.get("max_output_tokens")
5169 .and_then(serde_json::Value::as_u64),
5170 Some(2048)
5171 );
5172 }
5173}
5174
5175#[cfg(test)]
5176mod send_retry_tests {
5177 use super::*;
5187 use agent_core::auth::{
5188 AccessToken, BrokerError, ProxyByteStream, ProxyRequest, ProxyResponse,
5189 };
5190 use agent_core::reasoning::ReasoningLevel;
5191 use async_trait::async_trait;
5192 use axum::{http::StatusCode, response::IntoResponse, routing::post as axum_post, Router};
5193 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
5194 use std::sync::{
5195 atomic::{AtomicUsize, Ordering},
5196 Arc,
5197 };
5198 use tokio::sync::mpsc;
5199
5200 const CODEX_SSE_SUCCESS: &str = concat!(
5202 "data: {\"type\":\"response.output_text.delta\",\"delta\":\"hello\"}\n\n",
5203 "data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n",
5204 "data: [DONE]\n\n",
5205 );
5206
5207 fn fake_codex_token() -> String {
5211 let payload = serde_json::json!({
5212 "https://api.openai.com/auth": { "chatgpt_account_id": "acct_test" }
5213 });
5214 format!("h.{}.s", URL_SAFE_NO_PAD.encode(payload.to_string()))
5215 }
5216
5217 struct TokenOnlyBroker;
5220
5221 #[async_trait]
5222 impl crate::auth::CredentialBroker for TokenOnlyBroker {
5223 async fn access_token(
5224 &self,
5225 _p: agent_core::auth::OAuthProviderId,
5226 ) -> Result<AccessToken, BrokerError> {
5227 Ok(AccessToken {
5228 token: fake_codex_token(),
5229 expires: u64::MAX,
5230 })
5231 }
5232 async fn proxy(&self, _request: ProxyRequest) -> Result<ProxyResponse, BrokerError> {
5233 Err(BrokerError::Denied("not implemented in stub".into()))
5234 }
5235 async fn proxy_stream(
5236 &self,
5237 _request: ProxyRequest,
5238 ) -> Result<ProxyByteStream, BrokerError> {
5239 Err(BrokerError::Denied("not implemented in stub".into()))
5240 }
5241 async fn anthropic_usage(&self) -> Result<serde_json::Value, BrokerError> {
5242 Err(BrokerError::Denied("not implemented in stub".into()))
5243 }
5244 async fn capabilities(&self) -> Result<Vec<agent_core::auth::ProviderStatus>, BrokerError> {
5245 Ok(vec![])
5246 }
5247 }
5248
5249 struct DenyingCountingBroker {
5250 access_calls: Arc<AtomicUsize>,
5251 }
5252
5253 #[async_trait]
5254 impl crate::auth::CredentialBroker for DenyingCountingBroker {
5255 async fn access_token(
5256 &self,
5257 _provider: agent_core::auth::OAuthProviderId,
5258 ) -> Result<AccessToken, BrokerError> {
5259 self.access_calls.fetch_add(1, Ordering::SeqCst);
5260 Err(BrokerError::Denied(
5261 "credential access must not occur".into(),
5262 ))
5263 }
5264
5265 async fn proxy(&self, _request: ProxyRequest) -> Result<ProxyResponse, BrokerError> {
5266 Err(BrokerError::Denied("not implemented in stub".into()))
5267 }
5268
5269 async fn proxy_stream(
5270 &self,
5271 _request: ProxyRequest,
5272 ) -> Result<ProxyByteStream, BrokerError> {
5273 Err(BrokerError::Denied("not implemented in stub".into()))
5274 }
5275
5276 async fn anthropic_usage(&self) -> Result<serde_json::Value, BrokerError> {
5277 Err(BrokerError::Denied("not implemented in stub".into()))
5278 }
5279
5280 async fn capabilities(&self) -> Result<Vec<agent_core::auth::ProviderStatus>, BrokerError> {
5281 Ok(vec![])
5282 }
5283 }
5284
5285 async fn spawn_mock_codex(
5288 fail_count: usize,
5289 fail_status: StatusCode,
5290 ) -> (String, Arc<AtomicUsize>) {
5291 let counter = Arc::new(AtomicUsize::new(0));
5292 let counter_clone = Arc::clone(&counter);
5293 let app = Router::new().route(
5294 "/codex/responses",
5295 axum_post(move || {
5296 let counter = Arc::clone(&counter_clone);
5297 async move {
5298 let n = counter.fetch_add(1, Ordering::SeqCst);
5299 if n < fail_count {
5300 (
5301 fail_status,
5302 [("content-type", "application/json")],
5303 "{\"error\":{\"message\":\"transient upstream sadness\"}}".to_string(),
5304 )
5305 .into_response()
5306 } else {
5307 (
5308 StatusCode::OK,
5309 [("content-type", "text/event-stream")],
5310 CODEX_SSE_SUCCESS.to_string(),
5311 )
5312 .into_response()
5313 }
5314 }
5315 }),
5316 );
5317 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5318 let addr = listener.local_addr().unwrap();
5319 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
5320 (format!("http://{addr}"), counter)
5321 }
5322
5323 async fn spawn_codex_sse(body: &'static str) -> String {
5324 let app = Router::new().route(
5325 "/codex/responses",
5326 axum_post(move || async move {
5327 (
5328 StatusCode::OK,
5329 [("content-type", "text/event-stream")],
5330 body,
5331 )
5332 }),
5333 );
5334 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5335 let addr = listener.local_addr().unwrap();
5336 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
5337 format!("http://{addr}")
5338 }
5339
5340 async fn spawn_empty_then_success_codex() -> (
5341 String,
5342 Arc<AtomicUsize>,
5343 Arc<std::sync::Mutex<Vec<bytes::Bytes>>>,
5344 ) {
5345 let counter = Arc::new(AtomicUsize::new(0));
5346 let bodies = Arc::new(std::sync::Mutex::new(Vec::new()));
5347 let app = Router::new().route(
5348 "/codex/responses",
5349 axum_post({
5350 let counter = Arc::clone(&counter);
5351 let bodies = Arc::clone(&bodies);
5352 move |body: bytes::Bytes| {
5353 let counter = Arc::clone(&counter);
5354 let bodies = Arc::clone(&bodies);
5355 async move {
5356 bodies.lock().unwrap().push(body);
5357 let n = counter.fetch_add(1, Ordering::SeqCst);
5358 let response = if n == 0 {
5359 concat!(
5360 "data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":7,\"output_tokens\":0}}}\n\n",
5361 "data: [DONE]\n\n",
5362 )
5363 } else {
5364 CODEX_SSE_SUCCESS
5365 };
5366 (
5367 StatusCode::OK,
5368 [("content-type", "text/event-stream")],
5369 response,
5370 )
5371 }
5372 }
5373 }),
5374 );
5375 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5376 let addr = listener.local_addr().unwrap();
5377 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
5378 (format!("http://{addr}"), counter, bodies)
5379 }
5380
5381 async fn run_codex(
5382 base_url: &str,
5383 max_retries: u32,
5384 ) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
5385 let cfg = ProviderConfig {
5386 base_url: base_url.to_string(),
5387 model: "gpt-5.6-sol".to_string(),
5389 provider: "openai-codex".to_string(),
5390 };
5391 let client = reqwest::Client::new();
5392 let broker: std::sync::Arc<dyn crate::auth::CredentialBroker> =
5393 std::sync::Arc::new(TokenOnlyBroker);
5394 let (tx, _rx) = mpsc::unbounded_channel();
5395 call_codex_stream_inner(
5396 &cfg,
5397 &client,
5398 &broker,
5399 &[],
5400 &Some("test".to_string()),
5401 &[],
5402 &tx,
5403 None,
5404 None,
5405 agent_core::reasoning::ReasoningLevel::Medium,
5406 crate::runtime::openai::catalog::CodexRequestRole::Foreground,
5407 &tokio_util::sync::CancellationToken::new(),
5408 max_retries,
5409 &crate::runtime::trace::TraceContext::disabled(),
5410 )
5411 .await
5412 }
5413
5414 async fn broken_chunk_server(
5417 scripts: Vec<(&'static str, bool)>,
5418 ) -> (
5419 String,
5420 Arc<std::sync::Mutex<Vec<Vec<u8>>>>,
5421 tokio::task::JoinHandle<()>,
5422 ) {
5423 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5424 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5425 let address = listener.local_addr().unwrap();
5426 let requests = Arc::new(std::sync::Mutex::new(Vec::new()));
5427 let seen = requests.clone();
5428 let task = tokio::spawn(async move {
5429 let mut attempt = 0;
5430 loop {
5431 let (mut socket, _) = listener.accept().await.unwrap();
5432 let mut request = Vec::new();
5433 let end = loop {
5434 let mut chunk = [0; 8192];
5435 let n = socket.read(&mut chunk).await.unwrap();
5436 if n == 0 {
5437 return;
5438 }
5439 request.extend_from_slice(&chunk[..n]);
5440 if let Some(end) = request.windows(4).position(|w| w == b"\r\n\r\n") {
5441 break end + 4;
5442 }
5443 };
5444 let length: usize = String::from_utf8_lossy(&request[..end])
5445 .lines()
5446 .find_map(|line| {
5447 line.to_ascii_lowercase()
5448 .strip_prefix("content-length: ")
5449 .map(str::to_owned)
5450 })
5451 .unwrap()
5452 .parse()
5453 .unwrap();
5454 while request.len() < end + length {
5455 let mut chunk = [0; 8192];
5456 let n = socket.read(&mut chunk).await.unwrap();
5457 if n == 0 {
5458 return;
5459 }
5460 request.extend_from_slice(&chunk[..n]);
5461 }
5462 seen.lock()
5463 .unwrap()
5464 .push(request[end..end + length].to_vec());
5465 let (body, broken) = scripts[attempt.min(scripts.len() - 1)];
5466 attempt += 1;
5467 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();
5468 socket
5469 .write_all(format!("{:x}\r\n{}\r\n", body.len(), body).as_bytes())
5470 .await
5471 .unwrap();
5472 if !broken {
5474 socket.write_all(b"0\r\n\r\n").await.unwrap();
5475 }
5476 socket.shutdown().await.unwrap();
5477 }
5478 });
5479 (format!("http://{address}"), requests, task)
5480 }
5481
5482 async fn codex_with_events(
5483 base: &str,
5484 retries: u32,
5485 cancel: &tokio_util::sync::CancellationToken,
5486 tx: &mpsc::UnboundedSender<StreamEvent>,
5487 ) -> Result<Value, super::super::net::BoxedProviderError> {
5488 let cfg = ProviderConfig {
5489 base_url: base.into(),
5490 model: "gpt-5.6-sol".into(),
5491 provider: "openai-codex".into(),
5492 };
5493 let broker: Arc<dyn crate::auth::CredentialBroker> = Arc::new(TokenOnlyBroker);
5494 call_codex_stream_inner(
5495 &cfg,
5496 &reqwest::Client::new(),
5497 &broker,
5498 &[],
5499 &Some("test".into()),
5500 &[],
5501 tx,
5502 None,
5503 None,
5504 agent_core::reasoning::ReasoningLevel::Medium,
5505 crate::runtime::openai::catalog::CodexRequestRole::Foreground,
5506 cancel,
5507 retries,
5508 &crate::runtime::trace::TraceContext::disabled(),
5509 )
5510 .await
5511 }
5512
5513 const PARTIAL_TOOL: &str = concat!(
5514 "data: {\"type\":\"response.output_text.delta\",\"delta\":\"discard me\"}\n\n",
5515 "data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"function_call\",\"call_id\":\"old_call\",\"name\":\"write\",\"arguments\":\"\"}}\n\n",
5516 "data: {\"type\":\"response.function_call_arguments.delta\",\"output_index\":0,\"delta\":\"{\\\"path\\\":\"}\n\n",
5517 );
5518 const COMPLETE_TOOL: &str = concat!(
5519 "data: {\"type\":\"response.output_text.delta\",\"delta\":\"accepted\"}\n\n",
5520 "data: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"type\":\"function_call\",\"call_id\":\"new_call\",\"name\":\"write\",\"arguments\":\"{\\\"path\\\":\\\"ok\\\"}\"}}\n\n",
5521 "data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":3,\"output_tokens\":2}}}\n\n",
5522 );
5523
5524 #[tokio::test]
5525 async fn interrupted_partial_tool_retries_identical_bytes_and_commits_only_success() {
5526 use crate::LlmEvent;
5527 let (url, requests, server) =
5528 broken_chunk_server(vec![(PARTIAL_TOOL, true), (COMPLETE_TOOL, false)]).await;
5529 let (tx, mut rx) = mpsc::unbounded_channel();
5530 let result = codex_with_events(&url, 1, &tokio_util::sync::CancellationToken::new(), &tx)
5531 .await
5532 .unwrap();
5533 server.abort();
5534 let requests = requests.lock().unwrap();
5535 assert_eq!(requests.len(), 2);
5536 assert_eq!(requests[0], requests[1]);
5537 let tools: Vec<_> = result["content"]
5538 .as_array()
5539 .unwrap()
5540 .iter()
5541 .filter(|v| v["type"] == "tool_use")
5542 .collect();
5543 assert_eq!(tools.len(), 1);
5544 assert_eq!(tools[0]["id"], "new_call");
5545 assert_eq!(result["content"][0]["text"], "accepted");
5546 let mut preview = String::from("prior completed round");
5547 let mut baseline = 0;
5548 let mut resets = 0;
5549 let mut input_tokens = 0;
5550 while let Ok(event) = rx.try_recv() {
5551 match event {
5552 StreamEvent::Llm(LlmEvent::ResponseStart) => baseline = preview.len(),
5553 StreamEvent::Llm(LlmEvent::ResponseReset) => {
5554 preview.truncate(baseline);
5555 resets += 1;
5556 }
5557 StreamEvent::Llm(LlmEvent::Text(t)) => preview.push_str(&t),
5558 StreamEvent::Session(crate::SessionEvent::Usage {
5559 input_tokens: n, ..
5560 }) => input_tokens += n,
5561 _ => {}
5562 }
5563 }
5564 assert_eq!(resets, 1);
5565 assert_eq!(preview, "prior completed roundaccepted");
5566 assert_eq!(input_tokens, 3);
5567 }
5568
5569 #[tokio::test]
5570 async fn completed_response_followed_by_chunk_error_is_not_replayed() {
5571 let (url, requests, server) = broken_chunk_server(vec![(CODEX_SSE_SUCCESS, true)]).await;
5572 assert!(run_codex(&url, 2).await.is_ok());
5573 server.abort();
5574 assert_eq!(requests.lock().unwrap().len(), 1);
5575 }
5576
5577 #[tokio::test]
5578 async fn clean_truncated_eof_retries_but_never_accepts_partial_tools() {
5579 let (url, requests, server) = broken_chunk_server(vec![(PARTIAL_TOOL, false)]).await;
5580 assert!(run_codex(&url, 1).await.is_err());
5581 server.abort();
5582 assert_eq!(requests.lock().unwrap().len(), 2);
5583 }
5584
5585 #[tokio::test]
5586 async fn broken_chunk_retry_budget_is_finite() {
5587 let (url, requests, server) = broken_chunk_server(vec![(PARTIAL_TOOL, true)]).await;
5588 assert!(run_codex(&url, 1).await.is_err());
5589 server.abort();
5590 assert_eq!(requests.lock().unwrap().len(), 2);
5591 }
5592
5593 #[tokio::test]
5594 async fn cancellation_during_stream_retry_prevents_second_dispatch() {
5595 let (url, requests, server) = broken_chunk_server(vec![(PARTIAL_TOOL, true)]).await;
5596 let (tx, mut rx) = mpsc::unbounded_channel();
5597 let cancel = tokio_util::sync::CancellationToken::new();
5598 let waiter = cancel.clone();
5599 let task = tokio::spawn(async move {
5600 while let Some(event) = rx.recv().await {
5601 if matches!(event, StreamEvent::Session(crate::SessionEvent::Retry(_))) {
5602 waiter.cancel();
5603 break;
5604 }
5605 }
5606 });
5607 let error = codex_with_events(&url, 10, &cancel, &tx).await.unwrap_err();
5608 task.await.unwrap();
5609 server.abort();
5610 assert_eq!(error.to_string(), "request canceled");
5611 assert_eq!(requests.lock().unwrap().len(), 1);
5612 }
5613
5614 #[tokio::test]
5615 async fn direct_ultra_tool_guard_precedes_broker_access() {
5616 let access_calls = Arc::new(AtomicUsize::new(0));
5617 let broker: Arc<dyn crate::auth::CredentialBroker> = Arc::new(DenyingCountingBroker {
5618 access_calls: Arc::clone(&access_calls),
5619 });
5620 let cfg = ProviderConfig {
5621 base_url: "http://127.0.0.1:1".to_string(),
5622 model: "gpt-5.6-sol".to_string(),
5623 provider: "openai-codex".to_string(),
5624 };
5625 let (tx, _rx) = mpsc::unbounded_channel();
5626
5627 let error = call_codex_stream_inner(
5628 &cfg,
5629 &reqwest::Client::new(),
5630 &broker,
5631 &[],
5632 &Some("test".to_string()),
5633 &[],
5634 &tx,
5635 None,
5636 None,
5637 ReasoningLevel::Ultra,
5638 crate::runtime::openai::catalog::CodexRequestRole::Foreground,
5639 &tokio_util::sync::CancellationToken::new(),
5640 0,
5641 &crate::runtime::trace::TraceContext::disabled(),
5642 )
5643 .await
5644 .expect_err("foreground Ultra without delegation tools must fail closed");
5645
5646 assert!(error.to_string().contains("subagent_start"), "{error}");
5647 assert_eq!(
5648 access_calls.load(Ordering::SeqCst),
5649 0,
5650 "preflight denial must happen before broker credential access"
5651 );
5652 }
5653
5654 #[tokio::test]
5655 async fn direct_codex_path_rejects_provider_identity_before_broker_access() {
5656 let access_calls = Arc::new(AtomicUsize::new(0));
5657 let broker: Arc<dyn crate::auth::CredentialBroker> = Arc::new(DenyingCountingBroker {
5658 access_calls: Arc::clone(&access_calls),
5659 });
5660 let cfg = ProviderConfig {
5661 base_url: "http://127.0.0.1:1".to_string(),
5662 model: "gpt-5.6-sol".to_string(),
5663 provider: "openrouter".to_string(),
5664 };
5665 let (tx, _rx) = mpsc::unbounded_channel();
5666
5667 let error = call_codex_stream_inner(
5668 &cfg,
5669 &reqwest::Client::new(),
5670 &broker,
5671 &[],
5672 &Some("test".to_string()),
5673 &[],
5674 &tx,
5675 None,
5676 None,
5677 ReasoningLevel::Medium,
5678 crate::runtime::openai::catalog::CodexRequestRole::Foreground,
5679 &tokio_util::sync::CancellationToken::new(),
5680 0,
5681 &crate::runtime::trace::TraceContext::disabled(),
5682 )
5683 .await
5684 .expect_err("the Codex seam must validate provider-qualified identity");
5685
5686 assert!(error.to_string().contains("openrouter"), "{error}");
5687 assert_eq!(
5688 access_calls.load(Ordering::SeqCst),
5689 0,
5690 "provider identity denial must precede broker credential access"
5691 );
5692 }
5693
5694 #[tokio::test]
5700 async fn codex_response_failed_event_is_a_terminal_error_not_empty_success() {
5701 let base_url = spawn_codex_sse(concat!(
5702 "data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"type\":\"server_error\",\"message\":\"ECHOED:secret prompt\"}}}\n\n",
5703 "data: [DONE]\n\n",
5704 ))
5705 .await;
5706 let err = run_codex(&base_url, 0)
5707 .await
5708 .expect_err("response.failed must fail the request");
5709 let msg = err.to_string();
5710 assert!(
5711 msg == format!("Codex{RESPONSES_CAPACITY_SUFFIX}"),
5712 "static transient failure message expected: {msg}"
5713 );
5714 assert!(
5715 !msg.contains("ECHOED") && !msg.contains("secret prompt"),
5716 "provider error body must never surface: {msg}"
5717 );
5718 }
5719
5720 #[tokio::test]
5723 async fn codex_response_incomplete_event_is_a_terminal_error() {
5724 let base_url = spawn_codex_sse(concat!(
5725 "data: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\n\n",
5726 "data: {\"type\":\"response.incomplete\",\"response\":{\"incomplete_details\":{\"reason\":\"max_output_tokens\"}}}\n\n",
5727 "data: [DONE]\n\n",
5728 ))
5729 .await;
5730 let err = run_codex(&base_url, 0)
5731 .await
5732 .expect_err("response.incomplete must fail the request");
5733 assert!(
5734 err.to_string()
5735 .starts_with("Codex response was incomplete."),
5736 "got: {err}"
5737 );
5738 }
5739
5740 #[tokio::test]
5743 async fn codex_eof_without_terminal_event_fails_closed() {
5744 let base_url = spawn_codex_sse(
5745 "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\"}}\n\n",
5746 )
5747 .await;
5748 let err = run_codex(&base_url, 0)
5749 .await
5750 .expect_err("EOF without terminal event must fail");
5751 assert!(
5752 err.to_string()
5753 .starts_with("Codex response stream ended without a terminal event."),
5754 "got: {err}"
5755 );
5756 }
5757
5758 #[tokio::test]
5759 async fn codex_completed_without_output_retries_identical_bytes_then_succeeds() {
5760 let (base_url, counter, bodies) = spawn_empty_then_success_codex().await;
5761 let result = run_codex(&base_url, 1)
5762 .await
5763 .expect("one empty-success stream with retries available must not abort the turn");
5764
5765 assert_eq!(counter.load(Ordering::SeqCst), 2, "expected one retry");
5766 let received = bodies.lock().unwrap();
5767 assert_eq!(received.len(), 2, "both attempts must reach the endpoint");
5768 assert_eq!(
5769 received[0], received[1],
5770 "the logical request must resend byte-identical content"
5771 );
5772 assert_eq!(result["content"][0]["text"], "hello");
5773 }
5774
5775 async fn spawn_header_recording_codex(
5778 fail_count: usize,
5779 ) -> (String, Arc<std::sync::Mutex<Vec<(String, String, String)>>>) {
5780 let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
5781 let counter = Arc::new(AtomicUsize::new(0));
5782 let app = Router::new().route(
5783 "/codex/responses",
5784 axum_post({
5785 let seen = Arc::clone(&seen);
5786 move |headers: axum::http::HeaderMap, body: bytes::Bytes| {
5787 let seen = Arc::clone(&seen);
5788 let counter = Arc::clone(&counter);
5789 async move {
5790 let get = |name: &str| {
5791 headers
5792 .get(name)
5793 .and_then(|v| v.to_str().ok())
5794 .unwrap_or("")
5795 .to_string()
5796 };
5797 let key = serde_json::from_slice::<Value>(&body)
5798 .ok()
5799 .and_then(|b| b["prompt_cache_key"].as_str().map(str::to_string))
5800 .unwrap_or_default();
5801 seen.lock()
5802 .unwrap()
5803 .push((get("session-id"), get("thread-id"), key));
5804 if counter.fetch_add(1, Ordering::SeqCst) < fail_count {
5805 (StatusCode::INTERNAL_SERVER_ERROR, "{}".to_string()).into_response()
5806 } else {
5807 (
5808 StatusCode::OK,
5809 [("content-type", "text/event-stream")],
5810 CODEX_SSE_SUCCESS.to_string(),
5811 )
5812 .into_response()
5813 }
5814 }
5815 }
5816 }),
5817 );
5818 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5819 let addr = listener.local_addr().unwrap();
5820 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
5821 (format!("http://{addr}"), seen)
5822 }
5823
5824 #[tokio::test]
5829 async fn codex_requests_carry_session_and_thread_id_equal_to_the_cache_key() {
5830 let (base_url, seen) = spawn_header_recording_codex(1).await;
5831 run_codex(&base_url, 2)
5832 .await
5833 .expect("one transient 500 then success");
5834 let seen = seen.lock().unwrap();
5835 assert_eq!(seen.len(), 2, "first attempt + one retry");
5836 for (session_id, thread_id, key) in seen.iter() {
5837 assert!(
5838 key.starts_with("synaps-"),
5839 "prompt_cache_key in body: {key:?}"
5840 );
5841 assert_eq!(session_id, key, "session-id header = prompt_cache_key");
5842 assert_eq!(thread_id, key, "thread-id header = prompt_cache_key");
5843 }
5844 assert_eq!(seen[0], seen[1], "a retry keeps the same cache routing");
5845 }
5846
5847 #[tokio::test]
5848 async fn codex_retries_transient_500_then_succeeds() {
5849 let (base_url, counter) = spawn_mock_codex(1, StatusCode::INTERNAL_SERVER_ERROR).await;
5850 let result = run_codex(&base_url, 2)
5851 .await
5852 .expect("one transient 500 with retries available must not abort the turn");
5853 assert_eq!(
5854 counter.load(Ordering::SeqCst),
5855 2,
5856 "expected exactly one retry"
5857 );
5858 let text = result["content"][0]["text"].as_str().unwrap_or_default();
5859 assert_eq!(text, "hello");
5860 }
5861
5862 #[tokio::test]
5863 async fn codex_response_failed_surfaces_typed_provider_error_not_empty_response() {
5864 const BODY: &str = concat!(
5865 "data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"type\":\"server_error\",\"message\":\"ECHOED:private prompt\"}}}\n\n",
5866 "data: [DONE]\n\n",
5867 );
5868 let base_url = spawn_codex_sse(BODY).await;
5869 let err = run_codex(&base_url, 0).await.expect_err("must fail");
5870 let msg = err.to_string();
5871 assert_eq!(msg, format!("Codex{RESPONSES_CAPACITY_SUFFIX}"));
5872 assert!(!msg.contains("empty response"), "{msg}");
5873 assert!(!msg.contains("ECHOED"), "provider text leaked: {msg}");
5874 assert!(
5875 !msg.contains("private prompt"),
5876 "provider text leaked: {msg}"
5877 );
5878 }
5879
5880 #[tokio::test]
5881 async fn codex_response_incomplete_surfaces_typed_provider_error_not_empty_response() {
5882 const BODY: &str = concat!(
5883 "data: {\"type\":\"response.incomplete\",\"response\":{\"incomplete_details\":{\"reason\":\"max_output_tokens\"}}}\n\n",
5884 "data: [DONE]\n\n",
5885 );
5886 let base_url = spawn_codex_sse(BODY).await;
5887 let err = run_codex(&base_url, 0).await.expect_err("must fail");
5888 let msg = err.to_string();
5889 assert!(msg.starts_with("Codex response was incomplete."), "{msg}");
5890 assert!(!msg.contains("empty response"), "{msg}");
5891 assert!(
5892 !msg.contains("max_output_tokens"),
5893 "provider text leaked: {msg}"
5894 );
5895 }
5896
5897 #[tokio::test]
5898 async fn codex_clean_eof_without_terminal_fails_closed() {
5899 let base_url = spawn_codex_sse(
5900 "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_123\"}}\n\n",
5901 )
5902 .await;
5903 let err = run_codex(&base_url, 0).await.expect_err("must fail");
5904 assert!(
5905 err.to_string()
5906 .starts_with("Codex response stream ended without a terminal event."),
5907 "{err}"
5908 );
5909 }
5910
5911 #[tokio::test]
5912 async fn codex_retries_429_then_succeeds() {
5913 let (base_url, counter) = spawn_mock_codex(1, StatusCode::TOO_MANY_REQUESTS).await;
5914 run_codex(&base_url, 2)
5915 .await
5916 .expect("429 is transient — must retry");
5917 assert_eq!(counter.load(Ordering::SeqCst), 2);
5918 }
5919
5920 #[tokio::test]
5921 async fn codex_zero_retries_fails_fast_with_status_error() {
5922 let (base_url, counter) =
5923 spawn_mock_codex(usize::MAX, StatusCode::INTERNAL_SERVER_ERROR).await;
5924 let err = run_codex(&base_url, 0).await.expect_err("must fail");
5925 assert_eq!(counter.load(Ordering::SeqCst), 1, "no retries budgeted");
5926 let msg = err.to_string();
5927 assert!(
5928 msg.starts_with("codex request failed: 500"),
5929 "status must survive for classification: {msg}"
5930 );
5931 }
5932
5933 #[tokio::test]
5934 async fn codex_does_not_retry_client_errors() {
5935 let (base_url, counter) = spawn_mock_codex(usize::MAX, StatusCode::BAD_REQUEST).await;
5936 let err = run_codex(&base_url, 3).await.expect_err("must fail");
5937 assert_eq!(
5938 counter.load(Ordering::SeqCst),
5939 1,
5940 "400 is deterministic — retrying it never helps"
5941 );
5942 assert!(err.to_string().starts_with("codex request failed: 400"));
5943 }
5944
5945 #[tokio::test]
5946 async fn codex_exhausted_retries_reports_last_status() {
5947 let (base_url, counter) =
5948 spawn_mock_codex(usize::MAX, StatusCode::SERVICE_UNAVAILABLE).await;
5949 let err = run_codex(&base_url, 1).await.expect_err("must fail");
5950 assert_eq!(
5951 counter.load(Ordering::SeqCst),
5952 2,
5953 "initial attempt + 1 retry"
5954 );
5955 assert!(err.to_string().starts_with("codex request failed: 503"));
5956 }
5957
5958 use crate::runtime::openai::account_routing::{
5961 CodexAccountRouter, CodexQuotaEvidence, PinnedAccount,
5962 };
5963 use agent_core::auth::{Account, CredentialRef, OAuthProviderId};
5964
5965 fn fake_codex_token_for(account_id: &str) -> String {
5967 let payload = serde_json::json!({
5968 "https://api.openai.com/auth": { "chatgpt_account_id": account_id }
5969 });
5970 format!("h.{}.s", URL_SAFE_NO_PAD.encode(payload.to_string()))
5971 }
5972
5973 fn pinned(label: &str, account_id: &str, auto: bool) -> PinnedAccount {
5974 PinnedAccount {
5975 credential: CredentialRef::new(
5976 OAuthProviderId::OpenAiCodex,
5977 Account::parse(label).unwrap(),
5978 ),
5979 token: fake_codex_token_for(account_id),
5980 auto,
5981 }
5982 }
5983
5984 struct ScriptedRouter {
5987 first: PinnedAccount,
5988 candidates: std::sync::Mutex<std::collections::VecDeque<Option<PinnedAccount>>>,
5989 failover_calls: AtomicUsize,
5990 reported: std::sync::Mutex<Vec<(String, CodexQuotaEvidence)>>,
5991 }
5992
5993 impl ScriptedRouter {
5994 fn new(first: PinnedAccount, candidates: Vec<Option<PinnedAccount>>) -> Self {
5995 Self {
5996 first,
5997 candidates: std::sync::Mutex::new(candidates.into()),
5998 failover_calls: AtomicUsize::new(0),
5999 reported: std::sync::Mutex::new(Vec::new()),
6000 }
6001 }
6002 fn reports(&self) -> Vec<String> {
6003 self.reported
6004 .lock()
6005 .unwrap()
6006 .iter()
6007 .map(|(k, _)| k.clone())
6008 .collect()
6009 }
6010 }
6011
6012 #[async_trait]
6013 impl CodexAccountRouter for ScriptedRouter {
6014 async fn pin(&self, _model: &str) -> Result<PinnedAccount, String> {
6015 Ok(self.first.clone())
6016 }
6017 async fn failover(
6018 &self,
6019 _model: &str,
6020 _failed: &PinnedAccount,
6021 _evidence: &CodexQuotaEvidence,
6022 _failovers_so_far: u32,
6023 ) -> Result<Option<PinnedAccount>, String> {
6024 self.failover_calls.fetch_add(1, Ordering::SeqCst);
6025 Ok(self.candidates.lock().unwrap().pop_front().flatten())
6026 }
6027 async fn report_exhausted(&self, failed: &PinnedAccount, evidence: &CodexQuotaEvidence) {
6028 self.reported
6029 .lock()
6030 .unwrap()
6031 .push((failed.credential.storage_key(), *evidence));
6032 }
6033 }
6034
6035 #[derive(Clone, Debug)]
6037 struct Seen {
6038 account: String,
6039 bearer: String,
6040 body: bytes::Bytes,
6041 }
6042
6043 type Script = Vec<(StatusCode, Vec<(&'static str, &'static str)>, &'static str)>;
6047 async fn spawn_scripted_codex(script: Script) -> (String, Arc<std::sync::Mutex<Vec<Seen>>>) {
6048 let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
6049 let counter = Arc::new(AtomicUsize::new(0));
6050 let script = Arc::new(script);
6051 let app = Router::new().route(
6052 "/codex/responses",
6053 axum_post({
6054 let seen = Arc::clone(&seen);
6055 let counter = Arc::clone(&counter);
6056 let script = Arc::clone(&script);
6057 move |headers: axum::http::HeaderMap, body: bytes::Bytes| {
6058 let seen = Arc::clone(&seen);
6059 let counter = Arc::clone(&counter);
6060 let script = Arc::clone(&script);
6061 async move {
6062 let header = |name: &str| {
6063 headers
6064 .get(name)
6065 .and_then(|v| v.to_str().ok())
6066 .unwrap_or("")
6067 .to_string()
6068 };
6069 let bearer = header("authorization")
6070 .strip_prefix("Bearer ")
6071 .unwrap_or("")
6072 .to_string();
6073 seen.lock().unwrap().push(Seen {
6074 account: header("chatgpt-account-id"),
6075 bearer,
6076 body,
6077 });
6078 let n = counter.fetch_add(1, Ordering::SeqCst);
6079 let (status, hdrs, body) = &script[n.min(script.len() - 1)];
6080 let mut resp = axum::response::Response::builder().status(*status);
6081 for (k, v) in hdrs {
6082 resp = resp.header(*k, *v);
6083 }
6084 let ct = if status.is_success() {
6085 "text/event-stream"
6086 } else {
6087 "application/json"
6088 };
6089 resp.header("content-type", ct)
6090 .body(axum::body::Body::from(*body))
6091 .unwrap()
6092 }
6093 }
6094 }),
6095 );
6096 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6097 let addr = listener.local_addr().unwrap();
6098 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
6099 (format!("http://{addr}"), seen)
6100 }
6101
6102 async fn run_codex_with_router(
6103 base_url: &str,
6104 router: &dyn CodexAccountRouter,
6105 max_retries: u32,
6106 tx: &mpsc::UnboundedSender<StreamEvent>,
6107 ) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
6108 let cfg = ProviderConfig {
6109 base_url: base_url.to_string(),
6110 model: "gpt-5.6-sol".to_string(),
6111 provider: "openai-codex".to_string(),
6112 };
6113 call_codex_stream_with_router(
6114 &cfg,
6115 &reqwest::Client::new(),
6116 router,
6117 &[],
6118 &Some("test".to_string()),
6119 &[],
6120 tx,
6121 None,
6122 None,
6123 ReasoningLevel::Medium,
6124 crate::runtime::openai::catalog::CodexRequestRole::Foreground,
6125 &tokio_util::sync::CancellationToken::new(),
6126 max_retries,
6127 &crate::runtime::trace::TraceContext::disabled(),
6128 )
6129 .await
6130 }
6131
6132 const QUOTA_429: &str = r#"{"error":{"type":"usage_limit_reached","message":"ECHOED prompt","plan_type":"plus","resets_in_seconds":3600}}"#;
6133 const GENERIC_429: &str = r#"{"error":{"type":"rate_limit_exceeded","message":"slow down"}}"#;
6134
6135 fn notices(rx: &mut mpsc::UnboundedReceiver<StreamEvent>) -> Vec<String> {
6136 let mut out = Vec::new();
6137 while let Ok(ev) = rx.try_recv() {
6138 if let StreamEvent::Session(
6139 crate::SessionEvent::Notice(n) | crate::SessionEvent::Retry(n),
6140 ) = ev
6141 {
6142 out.push(n);
6143 }
6144 }
6145 out
6146 }
6147
6148 #[tokio::test]
6152 async fn quota_429_fails_over_once_to_distinct_account_with_paired_header() {
6153 let (url, seen) = spawn_scripted_codex(vec![
6154 (StatusCode::TOO_MANY_REQUESTS, vec![], QUOTA_429),
6155 (StatusCode::OK, vec![], CODEX_SSE_SUCCESS),
6156 ])
6157 .await;
6158 let router = ScriptedRouter::new(
6159 pinned("astra1", "acct_A", true),
6160 vec![Some(pinned("astra2", "acct_B", true))],
6161 );
6162 let (tx, mut rx) = mpsc::unbounded_channel();
6163 let result = run_codex_with_router(&url, &router, 3, &tx).await.unwrap();
6164 assert_eq!(result["content"][0]["text"], "hello");
6165 let seen = seen.lock().unwrap();
6166 assert_eq!(seen.len(), 2, "one failed attempt on A, one success on B");
6167 assert_eq!(seen[0].account, "acct_A");
6168 assert_eq!(seen[1].account, "acct_B", "header must follow the pinned token");
6169 assert_eq!(seen[0].bearer, fake_codex_token_for("acct_A"));
6173 assert_eq!(seen[1].bearer, fake_codex_token_for("acct_B"));
6174 assert_ne!(seen[0].bearer, seen[1].bearer);
6175 assert_eq!(seen[0].body, seen[1].body, "same request bytes, no rebuild");
6176 assert_eq!(router.failover_calls.load(Ordering::SeqCst), 1);
6177 assert_eq!(router.reports(), vec!["openai-codex@astra1".to_string()]);
6178 let notes = notices(&mut rx);
6179 assert!(
6180 notes.iter().any(|n| n.contains("'astra1'") && n.contains("'astra2'")),
6181 "operator must see the switch: {notes:?}"
6182 );
6183 assert!(
6184 notes.iter().all(|n| !n.contains("acct_") && !n.contains("h.")),
6185 "notices carry labels only, never ids/tokens: {notes:?}"
6186 );
6187 }
6188
6189 #[tokio::test]
6193 async fn quota_429_on_explicit_account_never_fails_over() {
6194 let (url, seen) = spawn_scripted_codex(vec![(
6195 StatusCode::TOO_MANY_REQUESTS,
6196 vec![],
6197 QUOTA_429,
6198 )])
6199 .await;
6200 let router = ScriptedRouter::new(
6201 pinned("astra1", "acct_A", false),
6202 vec![Some(pinned("astra2", "acct_B", true))],
6203 );
6204 let (tx, _rx) = mpsc::unbounded_channel();
6205 let err = run_codex_with_router(&url, &router, 5, &tx)
6206 .await
6207 .expect_err("explicit account must fail, not switch");
6208 assert_eq!(err.to_string(), format!("Codex{RESPONSES_QUOTA_SUFFIX}"));
6209 assert!(!err.to_string().contains("ECHOED"));
6210 assert_eq!(seen.lock().unwrap().len(), 1, "no retry on exhausted seat");
6211 assert_eq!(router.failover_calls.load(Ordering::SeqCst), 0);
6212 assert_eq!(router.reports(), vec!["openai-codex@astra1".to_string()]);
6214 }
6215
6216 #[tokio::test]
6219 async fn generic_429_retries_same_account_and_never_switches() {
6220 let (url, seen) = spawn_scripted_codex(vec![
6221 (StatusCode::TOO_MANY_REQUESTS, vec![], GENERIC_429),
6222 (StatusCode::OK, vec![], CODEX_SSE_SUCCESS),
6223 ])
6224 .await;
6225 let router = ScriptedRouter::new(
6226 pinned("astra1", "acct_A", true),
6227 vec![Some(pinned("astra2", "acct_B", true))],
6228 );
6229 let (tx, _rx) = mpsc::unbounded_channel();
6230 run_codex_with_router(&url, &router, 2, &tx).await.unwrap();
6231 let seen = seen.lock().unwrap();
6232 assert_eq!(seen.len(), 2);
6233 assert!(seen.iter().all(|r| r.account == "acct_A"));
6234 assert!(seen.iter().all(|r| r.bearer == fake_codex_token_for("acct_A")));
6235 assert_eq!(router.failover_calls.load(Ordering::SeqCst), 0);
6236 assert!(router.reports().is_empty());
6237 }
6238
6239 #[tokio::test]
6241 async fn quota_429_by_headers_fails_over() {
6242 let (url, seen) = spawn_scripted_codex(vec![
6243 (
6244 StatusCode::TOO_MANY_REQUESTS,
6245 vec![
6246 ("x-codex-primary-used-percent", "100"),
6247 ("x-codex-primary-reset-after-seconds", "500"),
6248 ],
6249 "{}",
6250 ),
6251 (StatusCode::OK, vec![], CODEX_SSE_SUCCESS),
6252 ])
6253 .await;
6254 let router = ScriptedRouter::new(
6255 pinned("astra1", "acct_A", true),
6256 vec![Some(pinned("astra2", "acct_B", true))],
6257 );
6258 let (tx, _rx) = mpsc::unbounded_channel();
6259 run_codex_with_router(&url, &router, 2, &tx).await.unwrap();
6260 assert_eq!(seen.lock().unwrap()[1].account, "acct_B");
6261 let reported = router.reported.lock().unwrap();
6262 assert_eq!(reported[0].1.reset_after_secs, Some(500));
6263 }
6264
6265 #[tokio::test]
6268 async fn quota_429_with_no_eligible_candidate_fails_without_looping() {
6269 let (url, seen) = spawn_scripted_codex(vec![(
6270 StatusCode::TOO_MANY_REQUESTS,
6271 vec![],
6272 QUOTA_429,
6273 )])
6274 .await;
6275 let router = ScriptedRouter::new(pinned("astra1", "acct_A", true), vec![None]);
6276 let (tx, _rx) = mpsc::unbounded_channel();
6277 let err = run_codex_with_router(&url, &router, 10, &tx)
6278 .await
6279 .expect_err("must fail");
6280 assert_eq!(err.to_string(), format!("Codex{RESPONSES_QUOTA_SUFFIX}"));
6281 assert_eq!(seen.lock().unwrap().len(), 1);
6282 assert_eq!(router.failover_calls.load(Ordering::SeqCst), 1);
6283 }
6284
6285 #[tokio::test]
6288 async fn failover_to_same_account_is_refused() {
6289 let (url, seen) = spawn_scripted_codex(vec![(
6290 StatusCode::TOO_MANY_REQUESTS,
6291 vec![],
6292 QUOTA_429,
6293 )])
6294 .await;
6295 let router = ScriptedRouter::new(
6296 pinned("astra1", "acct_A", true),
6297 vec![Some(pinned("astra1", "acct_A", true))],
6298 );
6299 let (tx, _rx) = mpsc::unbounded_channel();
6300 let err = run_codex_with_router(&url, &router, 10, &tx)
6301 .await
6302 .expect_err("must fail");
6303 assert_eq!(err.to_string(), format!("Codex{RESPONSES_QUOTA_SUFFIX}"));
6304 assert_eq!(seen.lock().unwrap().len(), 1);
6305 }
6306
6307 #[tokio::test]
6310 async fn all_accounts_exhausted_stops_after_one_failover() {
6311 let (url, seen) = spawn_scripted_codex(vec![(
6312 StatusCode::TOO_MANY_REQUESTS,
6313 vec![],
6314 QUOTA_429,
6315 )])
6316 .await;
6317 let router = ScriptedRouter::new(
6318 pinned("astra1", "acct_A", true),
6319 vec![
6320 Some(pinned("astra2", "acct_B", true)),
6321 Some(pinned("astra3", "acct_C", true)),
6322 ],
6323 );
6324 let (tx, _rx) = mpsc::unbounded_channel();
6325 let err = run_codex_with_router(&url, &router, 10, &tx)
6326 .await
6327 .expect_err("must fail");
6328 assert_eq!(err.to_string(), format!("Codex{RESPONSES_QUOTA_SUFFIX}"));
6329 let seen = seen.lock().unwrap();
6330 assert_eq!(seen.len(), 2, "A then B, never C");
6331 assert_eq!(seen[0].account, "acct_A");
6332 assert_eq!(seen[1].account, "acct_B");
6333 assert_eq!(router.failover_calls.load(Ordering::SeqCst), 1);
6334 assert_eq!(
6335 router.reports(),
6336 vec![
6337 "openai-codex@astra1".to_string(),
6338 "openai-codex@astra2".to_string()
6339 ],
6340 "both exhausted seats reported for cooldown"
6341 );
6342 }
6343
6344 #[tokio::test]
6347 async fn in_stream_quota_before_output_fails_over() {
6348 const QUOTA_SSE: &str = concat!(
6349 "data: {\"type\":\"response.created\",\"response\":{\"id\":\"r\"}}\n\n",
6350 "data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"type\":\"insufficient_quota\",\"message\":\"ECHOED\"}}}\n\n",
6351 "data: [DONE]\n\n",
6352 );
6353 let (url, seen) = spawn_scripted_codex(vec![
6354 (StatusCode::OK, vec![], QUOTA_SSE),
6355 (StatusCode::OK, vec![], CODEX_SSE_SUCCESS),
6356 ])
6357 .await;
6358 let router = ScriptedRouter::new(
6359 pinned("astra1", "acct_A", true),
6360 vec![Some(pinned("astra2", "acct_B", true))],
6361 );
6362 let (tx, _rx) = mpsc::unbounded_channel();
6363 let result = run_codex_with_router(&url, &router, 2, &tx).await.unwrap();
6364 assert_eq!(result["content"][0]["text"], "hello");
6365 let seen = seen.lock().unwrap();
6366 assert_eq!(seen.len(), 2);
6367 assert_eq!(seen[1].account, "acct_B");
6368 assert_eq!(seen[1].bearer, fake_codex_token_for("acct_B"));
6369 }
6370
6371 #[tokio::test]
6375 async fn in_stream_quota_after_any_output_never_replays() {
6376 const QUOTA_AFTER_REASONING: &str = concat!(
6377 "data: {\"type\":\"response.reasoning_summary_text.delta\",\"delta\":\"thinking…\"}\n\n",
6378 "data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"code\":\"quota_exceeded\",\"message\":\"x\"}}}\n\n",
6379 "data: [DONE]\n\n",
6380 );
6381 let (url, seen) = spawn_scripted_codex(vec![
6382 (StatusCode::OK, vec![], QUOTA_AFTER_REASONING),
6383 (StatusCode::OK, vec![], CODEX_SSE_SUCCESS),
6384 ])
6385 .await;
6386 let router = ScriptedRouter::new(
6387 pinned("astra1", "acct_A", true),
6388 vec![Some(pinned("astra2", "acct_B", true))],
6389 );
6390 let (tx, _rx) = mpsc::unbounded_channel();
6391 let err = run_codex_with_router(&url, &router, 5, &tx)
6392 .await
6393 .expect_err("must not replay after output");
6394 assert_eq!(err.to_string(), format!("Codex{RESPONSES_QUOTA_SUFFIX}"));
6395 assert_eq!(seen.lock().unwrap().len(), 1);
6396 assert_eq!(router.failover_calls.load(Ordering::SeqCst), 0);
6397 assert_eq!(router.reports(), vec!["openai-codex@astra1".to_string()]);
6399 }
6400
6401 #[tokio::test]
6403 async fn in_stream_quota_after_text_never_replays() {
6404 const QUOTA_AFTER_TEXT: &str = concat!(
6405 "data: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\n\n",
6406 "data: {\"type\":\"error\",\"code\":\"usage_limit_reached\",\"message\":\"x\"}\n\n",
6407 );
6408 let (url, seen) = spawn_scripted_codex(vec![
6409 (StatusCode::OK, vec![], QUOTA_AFTER_TEXT),
6410 (StatusCode::OK, vec![], CODEX_SSE_SUCCESS),
6411 ])
6412 .await;
6413 let router = ScriptedRouter::new(
6414 pinned("astra1", "acct_A", true),
6415 vec![Some(pinned("astra2", "acct_B", true))],
6416 );
6417 let (tx, _rx) = mpsc::unbounded_channel();
6418 let err = run_codex_with_router(&url, &router, 5, &tx)
6419 .await
6420 .expect_err("must not replay after output");
6421 assert_eq!(err.to_string(), format!("Codex{RESPONSES_QUOTA_SUFFIX}"));
6422 assert_eq!(seen.lock().unwrap().len(), 1);
6423 assert_eq!(router.failover_calls.load(Ordering::SeqCst), 0);
6424 }
6425
6426 #[tokio::test]
6429 async fn usage_not_included_fails_fast_without_retry_or_failover() {
6430 let (url, seen) = spawn_scripted_codex(vec![(
6431 StatusCode::TOO_MANY_REQUESTS,
6432 vec![],
6433 r#"{"error":{"type":"usage_not_included","message":"no codex here"}}"#,
6434 )])
6435 .await;
6436 let router = ScriptedRouter::new(
6437 pinned("astra1", "acct_A", true),
6438 vec![Some(pinned("astra2", "acct_B", true))],
6439 );
6440 let (tx, _rx) = mpsc::unbounded_channel();
6441 let err = run_codex_with_router(&url, &router, 5, &tx)
6442 .await
6443 .expect_err("must fail");
6444 assert!(err.to_string().starts_with("codex request failed: 429"), "{err}");
6445 assert!(!err.to_string().contains("no codex here"));
6446 assert_eq!(seen.lock().unwrap().len(), 1);
6447 assert_eq!(router.failover_calls.load(Ordering::SeqCst), 0);
6448 assert!(router.reports().is_empty());
6449 }
6450
6451 #[tokio::test]
6456 async fn transport_retry_then_quota_then_failover_keeps_one_retry_budget() {
6457 let (url, seen) = spawn_scripted_codex(vec![
6458 (StatusCode::SERVICE_UNAVAILABLE, vec![], "{}"),
6459 (StatusCode::TOO_MANY_REQUESTS, vec![], QUOTA_429),
6460 (StatusCode::SERVICE_UNAVAILABLE, vec![], "{}"),
6461 (StatusCode::OK, vec![], CODEX_SSE_SUCCESS),
6462 ])
6463 .await;
6464 let router = ScriptedRouter::new(
6465 pinned("astra1", "acct_A", true),
6466 vec![Some(pinned("astra2", "acct_B", true))],
6467 );
6468 let (tx, _rx) = mpsc::unbounded_channel();
6469 run_codex_with_router(&url, &router, 2, &tx).await.unwrap();
6470 let seen = seen.lock().unwrap();
6471 let accounts: Vec<&str> = seen.iter().map(|r| r.account.as_str()).collect();
6472 assert_eq!(accounts, ["acct_A", "acct_A", "acct_B", "acct_B"]);
6473 assert_eq!(router.failover_calls.load(Ordering::SeqCst), 1);
6474 }
6475
6476 #[tokio::test]
6480 async fn failover_does_not_reset_transport_budget() {
6481 let (url, seen) = spawn_scripted_codex(vec![
6482 (StatusCode::SERVICE_UNAVAILABLE, vec![], "{}"),
6483 (StatusCode::TOO_MANY_REQUESTS, vec![], QUOTA_429),
6484 (StatusCode::SERVICE_UNAVAILABLE, vec![], "{}"),
6485 ])
6486 .await;
6487 let router = ScriptedRouter::new(
6488 pinned("astra1", "acct_A", true),
6489 vec![Some(pinned("astra2", "acct_B", true))],
6490 );
6491 let (tx, _rx) = mpsc::unbounded_channel();
6492 let err = run_codex_with_router(&url, &router, 1, &tx)
6493 .await
6494 .expect_err("B's 503 exceeds the shared budget");
6495 assert!(err.to_string().starts_with("codex request failed: 503"), "{err}");
6496 let accounts: Vec<String> = seen.lock().unwrap().iter().map(|r| r.account.clone()).collect();
6497 assert_eq!(accounts, ["acct_A", "acct_A", "acct_B"]);
6498 }
6499
6500 #[tokio::test]
6503 async fn explicit_named_account_in_stream_quota_never_rotates() {
6504 const QUOTA_SSE: &str = concat!(
6505 "data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"type\":\"insufficient_quota\",\"message\":\"x\"}}}\n\n",
6506 "data: [DONE]\n\n",
6507 );
6508 let (url, seen) = spawn_scripted_codex(vec![
6509 (StatusCode::OK, vec![], QUOTA_SSE),
6510 (StatusCode::OK, vec![], CODEX_SSE_SUCCESS),
6511 ])
6512 .await;
6513 let router = ScriptedRouter::new(
6514 pinned("astra1", "acct_A", false),
6515 vec![Some(pinned("astra2", "acct_B", true))],
6516 );
6517 let (tx, mut rx) = mpsc::unbounded_channel();
6518 let err = run_codex_with_router(&url, &router, 5, &tx)
6519 .await
6520 .expect_err("explicit named account must not rotate");
6521 assert_eq!(err.to_string(), format!("Codex{RESPONSES_QUOTA_SUFFIX}"));
6522 assert_eq!(seen.lock().unwrap().len(), 1);
6523 assert_eq!(router.failover_calls.load(Ordering::SeqCst), 0);
6524 assert!(notices(&mut rx).iter().all(|n| !n.contains("switched")));
6525 }
6526
6527 #[tokio::test]
6529 async fn explicit_default_account_quota_429_never_rotates() {
6530 let (url, seen) = spawn_scripted_codex(vec![
6531 (StatusCode::TOO_MANY_REQUESTS, vec![], QUOTA_429),
6532 (StatusCode::OK, vec![], CODEX_SSE_SUCCESS),
6533 ])
6534 .await;
6535 let router = ScriptedRouter::new(
6536 pinned("default", "acct_D", false),
6537 vec![Some(pinned("astra2", "acct_B", true))],
6538 );
6539 let (tx, _rx) = mpsc::unbounded_channel();
6540 let err = run_codex_with_router(&url, &router, 5, &tx)
6541 .await
6542 .expect_err("explicit default must not rotate");
6543 assert_eq!(err.to_string(), format!("Codex{RESPONSES_QUOTA_SUFFIX}"));
6544 assert_eq!(seen.lock().unwrap().len(), 1);
6545 assert_eq!(router.reports(), vec!["openai-codex".to_string()]);
6546 }
6547
6548 #[tokio::test]
6551 async fn in_stream_quota_after_reasoning_item_never_replays() {
6552 const REASONING_ITEM_THEN_QUOTA: &str = concat!(
6553 "data: {\"type\":\"response.created\",\"response\":{\"id\":\"r\"}}\n\n",
6554 "data: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"r\"}}\n\n",
6555 "data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"reasoning\",\"id\":\"rs_1\",\"summary\":[]}}\n\n",
6556 "data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"type\":\"insufficient_quota\",\"message\":\"x\"}}}\n\n",
6557 "data: [DONE]\n\n",
6558 );
6559 let (url, seen) = spawn_scripted_codex(vec![
6560 (StatusCode::OK, vec![], REASONING_ITEM_THEN_QUOTA),
6561 (StatusCode::OK, vec![], CODEX_SSE_SUCCESS),
6562 ])
6563 .await;
6564 let router = ScriptedRouter::new(
6565 pinned("astra1", "acct_A", true),
6566 vec![Some(pinned("astra2", "acct_B", true))],
6567 );
6568 let (tx, mut rx) = mpsc::unbounded_channel();
6569 let err = run_codex_with_router(&url, &router, 5, &tx)
6570 .await
6571 .expect_err("reasoning output already produced");
6572 assert_eq!(err.to_string(), format!("Codex{RESPONSES_QUOTA_SUFFIX}"));
6573 assert_eq!(seen.lock().unwrap().len(), 1);
6574 assert_eq!(router.failover_calls.load(Ordering::SeqCst), 0);
6575 while let Ok(ev) = rx.try_recv() {
6577 assert!(
6578 !matches!(ev, StreamEvent::Llm(crate::LlmEvent::Text(_))),
6579 "no text was forwarded"
6580 );
6581 }
6582 }
6583
6584 #[tokio::test]
6587 async fn metadata_only_prelude_does_not_block_failover() {
6588 const METADATA_THEN_QUOTA: &str = concat!(
6589 "data: {\"type\":\"response.created\",\"response\":{\"id\":\"r\"}}\n\n",
6590 "data: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"r\"}}\n\n",
6591 "data: {\"type\":\"error\",\"code\":\"usage_limit_reached\",\"message\":\"x\"}\n\n",
6592 );
6593 let (url, seen) = spawn_scripted_codex(vec![
6594 (StatusCode::OK, vec![], METADATA_THEN_QUOTA),
6595 (StatusCode::OK, vec![], CODEX_SSE_SUCCESS),
6596 ])
6597 .await;
6598 let router = ScriptedRouter::new(
6599 pinned("astra1", "acct_A", true),
6600 vec![Some(pinned("astra2", "acct_B", true))],
6601 );
6602 let (tx, _rx) = mpsc::unbounded_channel();
6603 run_codex_with_router(&url, &router, 2, &tx).await.unwrap();
6604 assert_eq!(seen.lock().unwrap()[1].account, "acct_B");
6605 }
6606
6607 #[tokio::test]
6611 async fn failover_candidate_without_account_claim_never_sends_mixed_headers() {
6612 let (url, seen) = spawn_scripted_codex(vec![
6613 (StatusCode::TOO_MANY_REQUESTS, vec![], QUOTA_429),
6614 (StatusCode::OK, vec![], CODEX_SSE_SUCCESS),
6615 ])
6616 .await;
6617 let claimless = PinnedAccount {
6618 credential: CredentialRef::new(
6619 OAuthProviderId::OpenAiCodex,
6620 Account::parse("astra2").unwrap(),
6621 ),
6622 token: format!(
6623 "h.{}.s",
6624 URL_SAFE_NO_PAD.encode(serde_json::json!({"sub": "nobody"}).to_string())
6625 ),
6626 auto: true,
6627 };
6628 let router = ScriptedRouter::new(pinned("astra1", "acct_A", true), vec![Some(claimless)]);
6629 let (tx, _rx) = mpsc::unbounded_channel();
6630 let err = run_codex_with_router(&url, &router, 5, &tx)
6631 .await
6632 .expect_err("claimless token cannot be paired");
6633 assert!(err.to_string().contains("account id"), "{err}");
6634 let seen = seen.lock().unwrap();
6635 assert_eq!(seen.len(), 1, "only A's attempt was sent");
6636 assert_eq!(seen[0].account, "acct_A");
6637 }
6638
6639 #[tokio::test]
6642 async fn router_failover_error_ends_turn() {
6643 struct FailingRouter(PinnedAccount);
6644 #[async_trait]
6645 impl CodexAccountRouter for FailingRouter {
6646 async fn pin(&self, _m: &str) -> Result<PinnedAccount, String> {
6647 Ok(self.0.clone())
6648 }
6649 async fn failover(
6650 &self,
6651 _m: &str,
6652 _f: &PinnedAccount,
6653 _e: &CodexQuotaEvidence,
6654 _n: u32,
6655 ) -> Result<Option<PinnedAccount>, String> {
6656 Err("broker unreachable".into())
6657 }
6658 async fn report_exhausted(&self, _f: &PinnedAccount, _e: &CodexQuotaEvidence) {}
6659 }
6660 let (url, seen) = spawn_scripted_codex(vec![(
6661 StatusCode::TOO_MANY_REQUESTS,
6662 vec![],
6663 QUOTA_429,
6664 )])
6665 .await;
6666 let router = FailingRouter(pinned("astra1", "acct_A", true));
6667 let (tx, _rx) = mpsc::unbounded_channel();
6668 let err = run_codex_with_router(&url, &router, 5, &tx)
6669 .await
6670 .expect_err("must fail");
6671 assert_eq!(err.to_string(), format!("Codex{RESPONSES_QUOTA_SUFFIX}"));
6672 assert_eq!(seen.lock().unwrap().len(), 1);
6673 }
6674
6675 #[tokio::test]
6679 async fn broker_adapter_default_trait_surface_is_explicit_and_fails_closed() {
6680 let (url, seen) = spawn_scripted_codex(vec![(
6681 StatusCode::TOO_MANY_REQUESTS,
6682 vec![],
6683 QUOTA_429,
6684 )])
6685 .await;
6686 let err = run_codex(&url, 5).await.expect_err("must fail");
6687 assert_eq!(err.to_string(), format!("Codex{RESPONSES_QUOTA_SUFFIX}"));
6688 assert_eq!(seen.lock().unwrap().len(), 1, "no retry, no failover");
6689 }
6690
6691 #[test]
6695 fn codex_retry_budget_lifts_configured_default_to_persistent_floor() {
6696 assert_eq!(codex_retry_budget(3), CODEX_PERSISTENT_RETRIES);
6697 assert_eq!(codex_retry_budget(0), CODEX_PERSISTENT_RETRIES);
6698 assert_eq!(codex_retry_budget(10), 10);
6699 }
6700
6701 #[test]
6704 fn codex_retry_budget_honors_larger_user_configuration() {
6705 assert_eq!(codex_retry_budget(15), 15);
6706 }
6707
6708 #[tokio::test]
6713 async fn a_stream_retry_is_a_typed_retry_event() {
6714 let (tx, mut rx) = mpsc::unbounded_channel();
6715 let cancel = tokio_util::sync::CancellationToken::new();
6716 let waiting = cancel.clone();
6717 let task = tokio::spawn(async move { wait_stream_retry(&tx, &waiting, 1, 3, true).await.is_ok() });
6718 let ev = rx.recv().await.expect("the retry is announced");
6719 cancel.cancel();
6720 assert!(!task.await.unwrap(), "cancel ends the wait");
6721 assert!(
6722 matches!(&ev, StreamEvent::Session(crate::SessionEvent::Retry(t)) if t.starts_with("Response incomplete — retrying")),
6723 "{ev:?}"
6724 );
6725 }
6726
6727 #[test]
6728 fn retry_delay_is_exponential_with_a_capped_tail() {
6729 assert_eq!(retry_delay(1).as_secs(), 1);
6730 assert_eq!(retry_delay(2).as_secs(), 2);
6731 assert_eq!(retry_delay(4).as_secs(), 8);
6732 assert_eq!(retry_delay(7).as_secs(), 64);
6733 assert_eq!(retry_delay(10).as_secs(), 64);
6734 }
6735
6736 const ECHO_SENTINEL: &str = "PH1-OAI-SENTINEL-2b8c4d-RAW-CONTENT";
6744
6745 struct CaptureWriter(Arc<std::sync::Mutex<Vec<u8>>>);
6747
6748 impl std::io::Write for CaptureWriter {
6749 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
6750 self.0.lock().unwrap().extend_from_slice(buf);
6751 Ok(buf.len())
6752 }
6753 fn flush(&mut self) -> std::io::Result<()> {
6754 Ok(())
6755 }
6756 }
6757
6758 fn capture_subscriber(
6759 buf: &Arc<std::sync::Mutex<Vec<u8>>>,
6760 ) -> impl tracing::Subscriber + Send + Sync {
6761 let sink = buf.clone();
6762 tracing_subscriber::fmt()
6763 .with_max_level(tracing::Level::TRACE)
6764 .with_writer(move || CaptureWriter(sink.clone()))
6765 .with_ansi(false)
6766 .finish()
6767 }
6768
6769 async fn spawn_hostile_echo_provider(status: StatusCode) -> String {
6773 let app = Router::new().fallback(move |body: String| async move {
6774 let envelope = serde_json::json!({
6775 "error": { "message": format!("ECHOED:{body}") }
6776 });
6777 (
6778 status,
6779 [("content-type", "application/json")],
6780 envelope.to_string(),
6781 )
6782 .into_response()
6783 });
6784 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6785 let addr = listener.local_addr().unwrap();
6786 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
6787 format!("http://{addr}")
6788 }
6789
6790 fn sentinel_request_body() -> Value {
6794 json!({
6795 "model": "gpt-test",
6796 "messages": [
6797 {"role": "system", "content": format!("system secret {ECHO_SENTINEL}")},
6798 {"role": "user", "content": ECHO_SENTINEL},
6799 ],
6800 "tools": [{
6801 "type": "function",
6802 "function": {
6803 "name": "ph1_secret_tool_zz",
6804 "description": "internal tool schema",
6805 "parameters": {"type": "object", "properties": {}}
6806 }
6807 }],
6808 })
6809 }
6810
6811 const BANNED_MARKERS: &[&str] = &[
6813 "ECHOED",
6814 ECHO_SENTINEL,
6815 "ph1_secret_tool_zz",
6816 "\"messages\"",
6817 "system secret",
6818 ];
6819
6820 fn assert_no_banned(haystack: &str, context: &str) {
6821 for banned in BANNED_MARKERS {
6822 assert!(
6823 !haystack.contains(banned),
6824 "provider body content `{banned}` leaked into {context}: {haystack}"
6825 );
6826 }
6827 }
6828
6829 #[tokio::test]
6830 async fn send_with_retries_non_retryable_error_never_surfaces_provider_body() {
6831 let url = spawn_hostile_echo_provider(StatusCode::BAD_REQUEST).await;
6832 let client = reqwest::Client::new();
6833 let body = sentinel_request_body();
6834
6835 let buf = Arc::new(std::sync::Mutex::new(Vec::new()));
6836 let _guard = tracing::subscriber::set_default(capture_subscriber(&buf));
6837
6838 let err = send_with_retries(
6839 SendTarget {
6840 label: "codex",
6841 url: &url,
6842 quota_probe: true,
6843 },
6844 || client.post(&url).json(&body),
6845 &tokio_util::sync::CancellationToken::new(),
6846 3,
6847 &mut 0,
6848 &mut tr::StreamAttempt::new(None),
6849 )
6850 .await
6851 .expect_err("400 must fail fast");
6852
6853 let msg = err.to_string();
6854 assert!(
6855 msg.starts_with("codex request failed: 400"),
6856 "status + label must survive for classification/usability: {msg}"
6857 );
6858 assert_no_banned(&msg, "the surfaced error");
6859 let logs = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
6860 assert_no_banned(&logs, "tracing output");
6861 }
6862
6863 async fn spawn_fixed_body_provider(status: StatusCode, body: String) -> String {
6865 let app = Router::new().fallback(move || {
6866 let body = body.clone();
6867 async move { (status, [("content-type", "application/json")], body).into_response() }
6868 });
6869 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6870 let addr = listener.local_addr().unwrap();
6871 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
6872 format!("http://{addr}")
6873 }
6874
6875 #[tokio::test]
6880 async fn codex_4xx_never_surfaces_provider_prose_in_error_or_logs() {
6881 const PROSE: &str = "ZZPROSE";
6882 let probes = [
6883 "The model can't accept 'ZZPROSE echo of the user prompt, it's long' here".to_string(),
6885 format!("Invalid 'input[0]': '{PROSE} AKIAIOSFODNN7EXAMPLE pls'"),
6887 format!("Your request about {PROSE} my private quarterly notes was rejected"),
6889 format!("Bad value \u{2018}{PROSE} one\u{2019} and \u{201c}{PROSE} two\u{201d} \u{ab}x\u{bb}"),
6891 format!("{PROSE} key:sk-proj-abc123 password=hunter2 https://h/x?token=abc"),
6893 format!("{PROSE}\u{202e}gnirts\u{202c} s\u{200b}k-\u{2066}live\u{2069}"),
6895 ];
6896 for prose in probes {
6897 let body = serde_json::json!({"error": {
6898 "message": prose,
6899 "type": "invalid_request_error",
6900 "code": "unsupported_value",
6901 "param": "reasoning.effort",
6902 }})
6903 .to_string();
6904 let url = spawn_fixed_body_provider(StatusCode::BAD_REQUEST, body).await;
6905 let client = reqwest::Client::new();
6906 let buf = Arc::new(std::sync::Mutex::new(Vec::new()));
6907 let _guard = tracing::subscriber::set_default(capture_subscriber(&buf));
6908 let err = send_with_retries(
6909 SendTarget {
6910 label: "codex",
6911 url: &url,
6912 quota_probe: true,
6913 },
6914 || client.post(&url).body("{}"),
6915 &tokio_util::sync::CancellationToken::new(),
6916 3,
6917 &mut 0,
6918 &mut tr::StreamAttempt::new(None),
6919 )
6920 .await
6921 .expect_err("400 must fail fast");
6922 let runtime =
6924 super::super::net::provider_error_to_runtime_for("openai-codex/gpt-5.6-sol", err);
6925 let msg = runtime.to_string();
6926 let logs = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
6927 for (where_, text) in [("error", &msg), ("logs", &logs)] {
6928 for banned in [
6929 PROSE, "can't", "AKIA", "sk-", "hunter2", "token=", "private", "gnirts",
6930 "\u{202e}", "\u{200b}", "\u{2066}", "\u{2018}", "\u{201c}",
6931 ] {
6932 assert!(
6933 !text.contains(banned),
6934 "provider prose `{banned}` leaked into {where_} for probe {prose:?}: {text}"
6935 );
6936 }
6937 }
6938 assert!(
6940 msg.contains("[invalid_request_error/unsupported_value, param reasoning.effort]"),
6941 "{msg}"
6942 );
6943 assert!(msg.contains("/effort"), "static explanation missing: {msg}");
6944 }
6945 }
6946
6947 #[tokio::test(start_paused = true)]
6948 async fn send_with_retries_retry_logs_never_contain_provider_body() {
6949 let url = spawn_hostile_echo_provider(StatusCode::SERVICE_UNAVAILABLE).await;
6950 let client = reqwest::Client::new();
6951 let body = sentinel_request_body();
6952
6953 let buf = Arc::new(std::sync::Mutex::new(Vec::new()));
6954 let _guard = tracing::subscriber::set_default(capture_subscriber(&buf));
6955
6956 let err = send_with_retries(
6957 SendTarget {
6958 label: "codex",
6959 url: &url,
6960 quota_probe: true,
6961 },
6962 || client.post(&url).json(&body),
6963 &tokio_util::sync::CancellationToken::new(),
6964 1,
6965 &mut 0,
6966 &mut tr::StreamAttempt::new(None),
6967 )
6968 .await
6969 .expect_err("persistent 503 must exhaust the budget");
6970
6971 let msg = err.to_string();
6972 assert!(
6973 msg.starts_with("codex request failed: 503"),
6974 "status + label must survive after exhaustion: {msg}"
6975 );
6976 assert_no_banned(&msg, "the surfaced error");
6977
6978 let logs = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
6979 assert!(
6980 logs.contains("retry 1/1"),
6981 "retry accounting must stay observable: {logs}"
6982 );
6983 assert!(
6984 logs.contains("503"),
6985 "retry log must keep the status for diagnosis: {logs}"
6986 );
6987 assert_no_banned(&logs, "retry tracing output");
6988 }
6989}
6990
6991#[cfg(test)]
6992mod interrupted_broker_tests {
6993 use super::*;
6994 use crate::auth::{
6995 AccessToken, BrokerError, CredentialBroker, ProxyByteStream, ProxyRequest, ProxyResponse,
6996 };
6997 use std::sync::{Arc, Mutex};
6998 use tokio_util::sync::CancellationToken;
6999
7000 struct Broker {
7001 bodies: Vec<(&'static str, Option<BrokerError>)>,
7002 seen: Mutex<Vec<ProxyRequest>>,
7003 }
7004 #[async_trait::async_trait]
7005 impl CredentialBroker for Broker {
7006 async fn access_token(
7007 &self,
7008 _: crate::auth::OAuthProviderId,
7009 ) -> Result<AccessToken, BrokerError> {
7010 unreachable!()
7011 }
7012 async fn proxy(&self, _: ProxyRequest) -> Result<ProxyResponse, BrokerError> {
7013 unreachable!()
7014 }
7015 async fn proxy_stream(
7016 &self,
7017 request: ProxyRequest,
7018 ) -> Result<ProxyByteStream, BrokerError> {
7019 let mut seen = self.seen.lock().unwrap();
7020 let (body, error) = &self.bodies[seen.len().min(self.bodies.len() - 1)];
7021 seen.push(request);
7022 let mut chunks = vec![Ok(bytes::Bytes::from_static(body.as_bytes()))];
7023 if let Some(error) = error {
7024 chunks.push(Err(error.clone()));
7025 }
7026 Ok(Box::pin(futures::stream::iter(chunks)))
7027 }
7028 async fn anthropic_usage(&self) -> Result<Value, BrokerError> {
7029 unreachable!()
7030 }
7031 async fn capabilities(&self) -> Result<Vec<agent_core::auth::ProviderStatus>, BrokerError> {
7032 Ok(vec![])
7033 }
7034 }
7035 const CHAT_PARTIAL: &str = concat!(
7036 "data: {\"choices\":[{\"delta\":{\"content\":\"discard\"}}]}\n\n",
7037 "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":7,\"completion_tokens\":1}}\n\n"
7038 );
7039 const CHAT_OK: &str =
7040 "data: {\"choices\":[{\"delta\":{\"content\":\"accepted\"}}]}\n\ndata: [DONE]\n\n";
7041 const XAI_PARTIAL: &str =
7042 "data: {\"type\":\"response.output_text.delta\",\"delta\":\"discard\"}\n\n";
7043 const XAI_OK: &str = "data: {\"type\":\"response.output_text.delta\",\"delta\":\"accepted\"}\n\ndata: {\"type\":\"response.completed\"}\n\n";
7044
7045 async fn drive(
7046 broker: Arc<Broker>,
7047 xai: bool,
7048 retries: u32,
7049 suppressed: bool,
7050 cancel: &CancellationToken,
7051 ) -> (
7052 Result<Value, super::super::net::BoxedProviderError>,
7053 Vec<StreamEvent>,
7054 ) {
7055 let cfg = ProviderConfig {
7056 base_url: "http://unused.invalid".into(),
7057 model: if xai { "grok-4.5" } else { "test" }.into(),
7058 provider: if xai { "xai-auth" } else { "local" }.into(),
7059 };
7060 let broker: Arc<dyn CredentialBroker> = broker;
7061 let (tx, mut rx) = mpsc::unbounded_channel();
7062 let trace = crate::runtime::trace::TraceContext::disabled();
7063 let result = if xai {
7064 call_xai_responses_stream_inner(
7065 &cfg,
7066 &broker,
7067 &[],
7068 &None,
7069 &[],
7070 &tx,
7071 None,
7072 agent_core::reasoning::ReasoningLevel::Adaptive,
7073 cancel,
7074 retries,
7075 &trace,
7076 true,
7077 )
7078 .await
7079 } else {
7080 call_oai_stream_inner(
7081 &cfg,
7082 &broker,
7083 &[],
7084 &None,
7085 &[],
7086 &tx,
7087 None,
7088 None,
7089 0,
7090 agent_core::reasoning::ReasoningLevel::Medium,
7091 cancel,
7092 &trace,
7093 true,
7094 suppressed,
7095 retries,
7096 )
7097 .await
7098 };
7099 let mut events = vec![];
7100 while let Ok(event) = rx.try_recv() {
7101 events.push(event);
7102 }
7103 (result, events)
7104 }
7105
7106 #[tokio::test(start_paused = true)]
7107 async fn broker_transport_recovery_preserves_usage_and_identical_bytes() {
7108 for xai in [false, true] {
7109 let broker = Arc::new(Broker {
7110 bodies: vec![
7111 (
7112 if xai { XAI_PARTIAL } else { CHAT_PARTIAL },
7113 Some(BrokerError::Transport(
7114 "unexpected EOF during chunk size line".into(),
7115 )),
7116 ),
7117 (if xai { XAI_OK } else { CHAT_OK }, None),
7118 ],
7119 seen: Mutex::new(vec![]),
7120 });
7121 let (result, events) =
7122 drive(broker.clone(), xai, 1, false, &CancellationToken::new()).await;
7123 assert_eq!(result.unwrap()["content"][0]["text"], "accepted");
7124 let seen = broker.seen.lock().unwrap();
7125 assert_eq!(seen.len(), 2);
7126 assert_eq!(seen[0].body_bytes, seen[1].body_bytes);
7127 assert_eq!(
7128 events
7129 .iter()
7130 .filter(|e| matches!(e, StreamEvent::Llm(crate::LlmEvent::ResponseReset)))
7131 .count(),
7132 1
7133 );
7134 if !xai {
7135 assert_eq!(
7136 events
7137 .iter()
7138 .filter_map(|e| match e {
7139 StreamEvent::Session(crate::SessionEvent::Usage {
7140 input_tokens,
7141 ..
7142 }) => Some(*input_tokens),
7143 _ => None,
7144 })
7145 .sum::<u64>(),
7146 7
7147 );
7148 }
7149 }
7150 }
7151 #[tokio::test(start_paused = true)]
7152 async fn broker_nontransport_denial_never_retries_and_precancel_never_dispatches() {
7153 for xai in [false, true] {
7154 let broker = Arc::new(Broker {
7155 bodies: vec![(
7156 if xai { XAI_PARTIAL } else { CHAT_PARTIAL },
7157 Some(BrokerError::Denied("blocked".into())),
7158 )],
7159 seen: Mutex::new(vec![]),
7160 });
7161 let (result, events) =
7162 drive(broker.clone(), xai, 10, false, &CancellationToken::new()).await;
7163 assert!(result.is_err());
7164 assert_eq!(broker.seen.lock().unwrap().len(), 1);
7165 assert!(!events
7166 .iter()
7167 .any(|e| matches!(e, StreamEvent::Llm(crate::LlmEvent::ResponseReset))));
7168 let canceled = CancellationToken::new();
7169 canceled.cancel();
7170 assert!(drive(broker.clone(), xai, 10, false, &canceled)
7171 .await
7172 .0
7173 .is_err());
7174 assert_eq!(broker.seen.lock().unwrap().len(), 1);
7175 }
7176 }
7177 #[tokio::test(start_paused = true)]
7178 async fn suppressed_retry_emits_no_llm_previews() {
7179 let broker = Arc::new(Broker {
7180 bodies: vec![
7181 (CHAT_PARTIAL, Some(BrokerError::Transport("eof".into()))),
7182 (CHAT_OK, None),
7183 ],
7184 seen: Mutex::new(vec![]),
7185 });
7186 let (result, events) = drive(broker, false, 1, true, &CancellationToken::new()).await;
7187 assert!(result.is_ok());
7188 assert!(!events.iter().any(|e| matches!(e, StreamEvent::Llm(_))));
7189 }
7190 #[test]
7191 fn exhaustion_is_transient_for_auto_driver_but_not_arbitrary_error_prose() {
7192 use crate::extensions::session_driver::{classify_error, Outcome};
7193 assert_eq!(
7194 classify_error(STREAM_INTERRUPTED),
7195 (Outcome::ProviderError, "transient".into())
7196 );
7197 assert_eq!(
7198 classify_error(&format!("API error: {STREAM_INTERRUPTED}")),
7199 (Outcome::ProviderError, "transient".into())
7200 );
7201 assert_eq!(
7202 classify_error(&format!("denied: {STREAM_INTERRUPTED}")).0,
7203 Outcome::Blocked
7204 );
7205 }
7206}
7207
7208#[cfg(test)]
7209mod muse_tests {
7210 use super::*;
7213 use crate::auth::{
7214 AccessToken, BrokerError, CredentialBroker, ProxyByteStream, ProxyMethod, ProxyRequest,
7215 ProxyResponse,
7216 };
7217 use agent_core::reasoning::ReasoningLevel;
7218 use std::sync::{Arc, Mutex};
7219
7220 #[test]
7221 fn body_mirrors_official_client_and_adaptive_omits_reasoning() {
7222 let input = json!([{"role":"user","content":[{"type":"input_text","text":"hi"}]}]);
7223 let tool = json!({"type":"function","name":"bash","parameters":{"type":"object"}});
7224 let key = muse_prompt_cache_key("sys", input.as_array().unwrap().first());
7225 let body = build_muse_body(
7226 "muse-wonder-1.0",
7227 ReasoningLevel::Adaptive,
7228 input.clone(),
7229 "sys",
7230 vec![tool.clone()],
7231 Some(2048),
7232 &key,
7233 )
7234 .unwrap();
7235 assert_eq!(body["model"], "muse-wonder-1.0");
7236 assert_eq!(body["instructions"], "sys");
7237 assert_eq!(body["input"], input);
7238 assert_eq!(body["store"], false);
7239 assert_eq!(body["stream"], true);
7240 assert_eq!(body["tools"], json!([tool]));
7241 assert_eq!(body["max_output_tokens"], 2048);
7242 assert_eq!(body["prompt_cache_key"], key.as_str());
7243 assert!(
7244 body.get("reasoning").is_none(),
7245 "adaptive = provider default"
7246 );
7247 assert!(body.get("messages").is_none());
7248 assert!(body.get("temperature").is_none());
7249
7250 let bare = build_muse_body(
7252 "muse-wonder-1.0",
7253 ReasoningLevel::Adaptive,
7254 json!([]),
7255 "",
7256 Vec::new(),
7257 None,
7258 &key,
7259 )
7260 .unwrap();
7261 for absent in ["instructions", "tools", "max_output_tokens", "reasoning"] {
7262 assert!(bare.get(absent).is_none(), "{absent}");
7263 }
7264 }
7265
7266 #[test]
7267 fn named_effort_requires_exact_live_evidence_before_any_io() {
7268 let _guard = crate::runtime::openai::catalog::muse::test_cache_guard();
7271 crate::runtime::openai::catalog::capability_cache::replace_provider("muse", &[]);
7272 for level in [
7275 ReasoningLevel::Off,
7276 ReasoningLevel::High,
7277 ReasoningLevel::Max,
7278 ] {
7279 let err = build_muse_body(
7280 "muse-wonder-1.0",
7281 level,
7282 json!([]),
7283 "",
7284 Vec::new(),
7285 None,
7286 "k",
7287 )
7288 .unwrap_err();
7289 assert!(err.contains("adaptive"), "{level}: {err}");
7290 }
7291 assert!(build_muse_body(
7293 "never-cataloged-zz9",
7294 ReasoningLevel::Adaptive,
7295 json!([]),
7296 "",
7297 Vec::new(),
7298 None,
7299 "k"
7300 )
7301 .is_ok());
7302 assert!(build_muse_body(
7303 "Bad Id",
7304 ReasoningLevel::Adaptive,
7305 json!([]),
7306 "",
7307 Vec::new(),
7308 None,
7309 "k"
7310 )
7311 .is_err());
7312
7313 let models = crate::runtime::openai::catalog::parse_muse_catalog_models(
7314 r#"{"models":[{"id":"muse-body-probe-zz2","reasoning_effort_variants":
7315 [{"effort":"none"},{"effort":"xhigh"}]}]}"#,
7316 )
7317 .unwrap();
7318 crate::runtime::openai::catalog::capability_cache::replace_provider("muse", &models);
7319 for (level, effort) in [
7320 (ReasoningLevel::Off, "none"),
7321 (ReasoningLevel::XHigh, "xhigh"),
7322 ] {
7323 let body = build_muse_body(
7324 "muse-body-probe-zz2",
7325 level,
7326 json!([]),
7327 "",
7328 Vec::new(),
7329 None,
7330 "k",
7331 )
7332 .unwrap();
7333 assert_eq!(body["reasoning"], json!({"effort": effort}));
7334 }
7335 assert!(build_muse_body(
7336 "muse-body-probe-zz2",
7337 ReasoningLevel::High,
7338 json!([]),
7339 "",
7340 Vec::new(),
7341 None,
7342 "k"
7343 )
7344 .is_err());
7345 crate::runtime::openai::catalog::capability_cache::replace_provider("muse", &[]);
7346 }
7347
7348 #[test]
7349 fn cache_key_is_deterministic_namespaced_and_prefix_sensitive() {
7350 let first = json!({"role":"user","content":"a"});
7351 let k1 = muse_prompt_cache_key("sys", Some(&first));
7352 assert_eq!(k1, muse_prompt_cache_key("sys", Some(&first)));
7353 assert!(k1.starts_with("synaps-muse-"), "{k1}");
7354 assert_ne!(k1, codex_prompt_cache_key("sys", Some(&first)));
7355 assert_ne!(k1, muse_prompt_cache_key("sys2", Some(&first)));
7356 assert_ne!(
7357 k1,
7358 muse_prompt_cache_key("sys", Some(&json!({"role":"user","content":"b"})))
7359 );
7360 }
7361
7362 struct CaptureBroker {
7363 sse: &'static str,
7364 seen: Mutex<Vec<ProxyRequest>>,
7365 }
7366
7367 #[async_trait::async_trait]
7368 impl CredentialBroker for CaptureBroker {
7369 async fn access_token(
7370 &self,
7371 _: crate::auth::OAuthProviderId,
7372 ) -> Result<AccessToken, BrokerError> {
7373 unreachable!("the runtime never holds the Muse key")
7374 }
7375 async fn proxy(&self, _: ProxyRequest) -> Result<ProxyResponse, BrokerError> {
7376 unreachable!()
7377 }
7378 async fn proxy_stream(
7379 &self,
7380 request: ProxyRequest,
7381 ) -> Result<ProxyByteStream, BrokerError> {
7382 self.seen.lock().unwrap().push(request);
7383 Ok(Box::pin(futures::stream::iter(vec![Ok(
7384 bytes::Bytes::from_static(self.sse.as_bytes()),
7385 )])))
7386 }
7387 async fn anthropic_usage(&self) -> Result<Value, BrokerError> {
7388 unreachable!()
7389 }
7390 async fn capabilities(&self) -> Result<Vec<agent_core::auth::ProviderStatus>, BrokerError> {
7391 Ok(vec![])
7392 }
7393 }
7394
7395 const SSE: &str = concat!(
7396 "data: {\"type\":\"response.output_text.delta\",\"delta\":\"Hel\"}\n\n",
7397 "data: {\"type\":\"response.output_text.delta\",\"delta\":\"lo\"}\n\n",
7398 "data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"function_call\",",
7399 "\"call_id\":\"call_1\",\"name\":\"bash\",\"arguments\":\"{\\\"command\\\":\\\"ls\\\"}\"}}\n\n",
7400 "data: {\"type\":\"response.completed\",\"response\":{\"usage\":",
7401 "{\"input_tokens\":12,\"output_tokens\":3}}}\n\n",
7402 );
7403
7404 #[tokio::test]
7405 async fn turn_streams_through_broker_with_system_prompt_as_instructions() {
7406 let broker = Arc::new(CaptureBroker {
7407 sse: SSE,
7408 seen: Mutex::new(Vec::new()),
7409 });
7410 let dyn_broker: Arc<dyn CredentialBroker> = broker.clone();
7411 let cfg = ProviderConfig {
7412 base_url: crate::auth::muse::API_BASE_URL.into(),
7413 model: "muse-wonder-1.0".into(),
7414 provider: "muse".into(),
7415 };
7416 let tools = vec![json!({
7417 "name":"bash","description":"run","input_schema":{"type":"object","properties":{}}
7418 })];
7419 let messages: Vec<crate::SharedMessage> =
7420 vec![Arc::new(json!({"role":"user","content":"hi muse"}))];
7421 let (tx, mut rx) = mpsc::unbounded_channel::<StreamEvent>();
7422 let cancel = tokio_util::sync::CancellationToken::new();
7423
7424 let result = call_responses_route_inner(
7425 "muse",
7426 &cfg,
7427 &dyn_broker,
7428 &tools,
7429 &Some("You are Synaps.".into()),
7430 &messages,
7431 &tx,
7432 Some(4096),
7433 ReasoningLevel::Adaptive,
7434 &cancel,
7435 0,
7436 &crate::runtime::trace::TraceContext::disabled(),
7437 true,
7438 )
7439 .await
7440 .expect("muse turn completes");
7441
7442 let content = result["content"].as_array().unwrap();
7444 assert_eq!(content[0]["text"], "Hello");
7445 let tool_use = content
7446 .iter()
7447 .find(|block| block["type"] == "tool_use")
7448 .expect("tool call surfaced");
7449 assert_eq!(tool_use["name"], "bash");
7450 assert_eq!(tool_use["input"]["command"], "ls");
7451
7452 let seen = broker.seen.lock().unwrap();
7454 assert_eq!(seen.len(), 1);
7455 let request = &seen[0];
7456 assert_eq!(request.provider, "muse");
7457 assert_eq!(request.method, ProxyMethod::Post);
7458 assert_eq!(request.path, "/v1/responses");
7459 assert!(request.stream);
7460 let body = request.body.as_ref().unwrap();
7461 assert_eq!(body["model"], "muse-wonder-1.0");
7462 assert_eq!(body["instructions"], "You are Synaps.");
7463 assert_eq!(body["store"], false);
7464 assert_eq!(body["max_output_tokens"], 4096);
7465 assert!(body["prompt_cache_key"]
7466 .as_str()
7467 .unwrap()
7468 .starts_with("synaps-muse-"));
7469 let wire = body["input"].to_string();
7471 assert!(!wire.contains("You are Synaps."), "{wire}");
7472 assert!(wire.contains("hi muse"), "{wire}");
7473 assert_eq!(body["tools"][0]["name"], "bash");
7474 let bytes = request.body_bytes.as_ref().expect("exact wire bytes");
7476 assert_eq!(
7477 serde_json::from_slice::<Value>(bytes).unwrap(),
7478 body.clone()
7479 );
7480
7481 drop(tx);
7482 let mut streamed = String::new();
7483 while let Ok(event) = rx.try_recv() {
7484 if let StreamEvent::Llm(crate::runtime::types::LlmEvent::Text(text)) = event {
7485 streamed.push_str(&text);
7486 }
7487 }
7488 assert_eq!(streamed, "Hello");
7489 }
7490
7491 #[test]
7492 fn dangling_preamble_trigger_cases() {
7493 assert_eq!(
7495 muse_dangling_preamble(false, Some("commentary"), "Now checking prod logs."),
7496 Some("commentary_phase")
7497 );
7498 assert_eq!(
7500 muse_dangling_preamble(
7501 false,
7502 None,
7503 "Key facts assembled. Blocking question before I build anything:\n"
7504 ),
7505 Some("colon_preamble")
7506 );
7507 assert_eq!(
7508 muse_dangling_preamble(
7509 false,
7510 Some("final_answer"),
7511 "Now checking whether prod logs show the error:"
7512 ),
7513 Some("colon_preamble")
7514 );
7515 assert_eq!(
7517 muse_dangling_preamble(true, Some("commentary"), "Running:"),
7518 None
7519 );
7520 assert_eq!(muse_dangling_preamble(false, None, "All done."), None);
7522 assert_eq!(muse_dangling_preamble(false, None, " "), None);
7523 assert_eq!(
7524 muse_dangling_preamble(false, Some("final_answer"), "Which format?"),
7525 None
7526 );
7527 let long = format!("{}:", "x".repeat(PREAMBLE_MAX_CHARS));
7528 assert_eq!(muse_dangling_preamble(false, None, &long), None);
7529 let edge = format!("{}:", "x".repeat(PREAMBLE_MAX_CHARS - 1));
7530 assert_eq!(
7531 muse_dangling_preamble(false, None, &edge),
7532 Some("colon_preamble")
7533 );
7534 }
7535
7536 #[test]
7537 fn preamble_nudge_fires_at_most_once_per_turn() {
7538 let flagged =
7539 json!({"role":"assistant","content":[],DANGLING_PREAMBLE_KEY:"colon_preamble"});
7540 let clean = json!({"role":"assistant","content":[]});
7541 let mut nudged = false;
7542 assert_eq!(take_preamble_nudge(&clean, &mut nudged, false, true), None);
7543 assert!(!nudged, "a clean response must not consume the one nudge");
7544 assert_eq!(
7545 take_preamble_nudge(&flagged, &mut nudged, false, true).as_deref(),
7546 Some("colon_preamble")
7547 );
7548 assert!(nudged);
7549 assert_eq!(
7551 take_preamble_nudge(&flagged, &mut nudged, false, true),
7552 None
7553 );
7554 let mut fresh = false;
7556 assert_eq!(take_preamble_nudge(&flagged, &mut fresh, true, true), None);
7557 assert_eq!(
7558 take_preamble_nudge(&flagged, &mut fresh, false, false),
7559 None
7560 );
7561 assert!(!fresh);
7562 }
7563
7564 #[test]
7565 fn preamble_nudge_env_switch_parses_off_values_only() {
7566 assert!(preamble_nudge_enabled_value(None));
7567 assert!(preamble_nudge_enabled_value(Some("1")));
7568 assert!(preamble_nudge_enabled_value(Some("")));
7569 for off in ["0", "false", "OFF", " no "] {
7570 assert!(!preamble_nudge_enabled_value(Some(off)), "{off}");
7571 }
7572 }
7573
7574 async fn muse_turn(sse: &'static str, messages: Vec<Value>) -> (Value, Value) {
7575 let broker = Arc::new(CaptureBroker {
7576 sse,
7577 seen: Mutex::new(Vec::new()),
7578 });
7579 let dyn_broker: Arc<dyn CredentialBroker> = broker.clone();
7580 let cfg = ProviderConfig {
7581 base_url: crate::auth::muse::API_BASE_URL.into(),
7582 model: "muse-wonder-1.0".into(),
7583 provider: "muse".into(),
7584 };
7585 let tools = vec![json!({
7586 "name":"bash","description":"run","input_schema":{"type":"object","properties":{}}
7587 })];
7588 let messages: Vec<crate::SharedMessage> = messages.into_iter().map(Arc::new).collect();
7589 let (tx, _rx) = mpsc::unbounded_channel::<StreamEvent>();
7590 let result = call_responses_route_inner(
7591 "muse",
7592 &cfg,
7593 &dyn_broker,
7594 &tools,
7595 &None,
7596 &messages,
7597 &tx,
7598 None,
7599 ReasoningLevel::Adaptive,
7600 &tokio_util::sync::CancellationToken::new(),
7601 0,
7602 &crate::runtime::trace::TraceContext::disabled(),
7603 true,
7604 )
7605 .await
7606 .expect("muse turn completes");
7607 let body = broker.seen.lock().unwrap()[0].body.clone().unwrap();
7608 (result, body)
7609 }
7610
7611 const PREAMBLE_SSE: &str = concat!(
7612 "data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":",
7613 "{\"type\":\"message\",\"role\":\"assistant\",\"content\":[]}}\n\n",
7614 "data: {\"type\":\"response.output_text.delta\",\"output_index\":0,",
7615 "\"delta\":\"Blocking question before I build anything:\"}\n\n",
7616 "data: {\"type\":\"response.completed\",\"response\":{\"usage\":",
7617 "{\"input_tokens\":12,\"output_tokens\":3}}}\n\n",
7618 );
7619
7620 #[tokio::test]
7621 async fn muse_route_flags_dangling_preamble_outside_content() {
7622 let (result, _) =
7623 muse_turn(PREAMBLE_SSE, vec![json!({"role":"user","content":"go"})]).await;
7624 assert_eq!(result[DANGLING_PREAMBLE_KEY], "colon_preamble");
7625 assert_eq!(
7627 result["content"],
7628 json!([{"type":"text","text":"Blocking question before I build anything:"}])
7629 );
7630 let (result, _) = muse_turn(SSE, vec![json!({"role":"user","content":"go"})]).await;
7632 assert!(result.get(DANGLING_PREAMBLE_KEY).is_none(), "{result}");
7633 }
7634
7635 #[tokio::test]
7636 async fn muse_request_replays_assistant_text_with_phase() {
7637 let (_, body) = muse_turn(
7638 SSE,
7639 vec![
7640 json!({"role":"user","content":"go"}),
7641 json!({"role":"assistant","content":[
7642 {"type":"text","text":"Checking:"},
7643 {"type":"tool_use","id":"call_a","name":"bash","input":{}},
7644 ]}),
7645 json!({"role":"user","content":[
7646 {"type":"tool_result","tool_use_id":"call_a","content":"ok"},
7647 ]}),
7648 json!({"role":"assistant","content":[{"type":"text","text":"Done."}]}),
7649 json!({"role":"user","content":"next"}),
7650 ],
7651 )
7652 .await;
7653 let input = body["input"].as_array().unwrap();
7654 assert_eq!(input[1]["phase"], "commentary");
7655 assert_eq!(input[1]["content"][0]["text"], "Checking:");
7656 assert_eq!(input[2]["type"], "function_call");
7657 assert_eq!(input[4]["phase"], "final_answer");
7658 assert_eq!(input[4]["content"][0]["type"], "output_text");
7659 }
7660}