1use super::translate;
7use super::types::{ChatMessage, OaiEvent, ProviderConfig, StreamOptions, ToolCall};
8use super::wire::StreamDecoder;
9use crate::runtime::trace::openai as tr;
10use crate::runtime::types::StreamEvent;
11use futures::StreamExt;
12use serde_json::{json, Value};
13use tokio::sync::mpsc;
14
15fn capture_oai_trace_signals(
19 events: &[OaiEvent],
20 attempt: &mut tr::StreamAttempt,
21 usage: &mut Option<crate::runtime::trace::UsageMeta>,
22) {
23 for ev in events {
24 if !matches!(ev, OaiEvent::Warning(_)) {
25 attempt.mark_first_model_event();
26 }
27 if let OaiEvent::Usage {
28 prompt_tokens,
29 completion_tokens,
30 cached_tokens,
31 } = ev
32 {
33 *usage = Some(tr::provider_usage(
34 u64::from(*prompt_tokens),
35 u64::from(*completion_tokens),
36 u64::from(*cached_tokens),
37 ));
38 }
39 }
40}
41
42pub(crate) const CODEX_PERSISTENT_RETRIES: u32 = 10;
50
51const RETRY_BACKOFF_EXP_CAP: u32 = 6;
55
56pub(crate) fn codex_retry_budget(configured_retries: u32) -> u32 {
61 configured_retries.max(CODEX_PERSISTENT_RETRIES)
62}
63
64fn retry_delay(attempt: u32) -> std::time::Duration {
66 std::time::Duration::from_millis(
67 1000 * 2u64.pow(attempt.saturating_sub(1).min(RETRY_BACKOFF_EXP_CAP)),
68 )
69}
70
71async fn send_with_retries(
91 label: &str,
92 url: &str,
93 build: impl Fn() -> reqwest::RequestBuilder,
94 cancel: &tokio_util::sync::CancellationToken,
95 max_retries: u32,
96 trace_attempt: &mut tr::StreamAttempt,
97) -> Result<reqwest::Response, Box<dyn std::error::Error + Send + Sync>> {
98 let mut attempt: u32 = 0;
99 loop {
100 match build().send().await {
101 Ok(resp) if resp.status().is_success() => {
102 trace_attempt.mark_headers();
103 return Ok(resp);
104 }
105 Ok(resp) => {
106 trace_attempt.mark_headers();
110 let status = resp.status();
111 let trace_rid = tr::provider_request_id_from_headers(resp.headers());
113 let retryable =
114 status.as_u16() == 408 || status.as_u16() == 429 || status.is_server_error();
115 drop(resp);
121 let code = format!("http_{}", status.as_u16());
122 if !retryable || attempt >= max_retries {
123 trace_attempt.finish_failed(&code, Some(status.as_u16()), trace_rid);
124 return Err(format!("{label} request failed: {status}").into());
125 }
126 attempt += 1;
127 let delay = retry_delay(attempt);
128 trace_attempt.attempt_failed(
129 tr::retry_class_for_status(status.as_u16()),
130 delay,
131 Some(status.as_u16()),
132 trace_rid,
133 &code,
134 );
135 tracing::warn!(
136 "{label} API retry {attempt}/{max_retries} after {delay:?}: {status}"
137 );
138 tokio::select! {
139 _ = tokio::time::sleep(delay) => {}
140 _ = cancel.cancelled() => {
141 trace_attempt.finish_canceled(None, None);
142 return Err("request canceled".into());
143 }
144 }
145 trace_attempt.restart_clock();
147 }
148 Err(e) => {
149 let localhost_refusal = e.is_connect() && url.contains("localhost");
150 let transient = e.is_timeout() || e.is_connect() || e.is_request();
151 if localhost_refusal || !transient || attempt >= max_retries {
152 tracing::warn!(
153 "{label} request failed (no retry): {}",
154 crate::core::error::error_chain_string(&e)
155 );
156 trace_attempt.finish_failed("transport_error", None, None);
157 return Err(e.into());
158 }
159 attempt += 1;
160 let delay = retry_delay(attempt);
161 trace_attempt.attempt_failed(
162 if e.is_timeout() {
163 crate::runtime::trace::RetryClass::Timeout
164 } else {
165 crate::runtime::trace::RetryClass::Other
166 },
167 delay,
168 None,
169 None,
170 "transport_error",
171 );
172 tracing::warn!(
173 "{label} transport retry {attempt}/{max_retries} after {delay:?}: {}",
174 crate::core::error::error_chain_string(&e)
175 );
176 tokio::select! {
177 _ = tokio::time::sleep(delay) => {}
178 _ = cancel.cancelled() => {
179 trace_attempt.finish_canceled(None, None);
180 return Err("request canceled".into());
181 }
182 }
183 trace_attempt.restart_clock();
185 }
186 }
187 }
188}
189
190#[allow(clippy::too_many_arguments)]
196pub(crate) async fn call_oai_stream_inner(
197 cfg: &ProviderConfig,
198 broker: &std::sync::Arc<dyn crate::auth::CredentialBroker>,
199 tools_schema: &[Value],
200 system_prompt: &Option<String>,
201 messages: &[crate::SharedMessage],
202 tx: &mpsc::UnboundedSender<StreamEvent>,
203 temperature: Option<f32>,
204 max_tokens: Option<u32>,
205 thinking_budget: u32,
206 reasoning_level: agent_core::reasoning::ReasoningLevel,
207 cancel: &tokio_util::sync::CancellationToken,
208 trace: &crate::runtime::trace::TraceContext,
209 exact_wire_bytes: bool,
210 suppress_stream_deltas: bool,
211) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
212 let (oai_tools, name_map) = translate::tools_to_oai(tools_schema);
213 let oai_messages = translate::messages_to_oai(messages, system_prompt, &name_map);
214 let tools_opt = if oai_tools.is_empty() {
215 None
216 } else {
217 Some(oai_tools)
218 };
219
220 let stream_options = if cfg.base_url.contains("googleapis.com") {
222 None
223 } else {
224 Some(StreamOptions {
225 include_usage: true,
226 })
227 };
228
229 let mut body = serde_json::Map::new();
230 body.insert("model".to_string(), json!(cfg.model.clone()));
231 body.insert("messages".to_string(), serde_json::to_value(oai_messages)?);
232 body.insert("stream".to_string(), json!(true));
233 if cfg.base_url.contains("api.openai.com") {
240 body.insert("store".to_string(), json!(false));
241 }
242 if let Some(stream_options) = stream_options {
243 body.insert(
244 "stream_options".to_string(),
245 serde_json::to_value(stream_options)?,
246 );
247 }
248 if let Some(max_tokens) = max_tokens {
249 body.insert("max_tokens".to_string(), json!(max_tokens));
250 }
251 if let Some(temperature) = temperature {
252 body.insert("temperature".to_string(), json!(temperature));
253 }
254 if let Some(tools) = tools_opt {
255 body.insert("tools".to_string(), serde_json::to_value(tools)?);
256 }
257 super::reasoning::apply_openai_reasoning_params(
258 &mut body,
259 super::reasoning::provider_for_key(&cfg.provider),
260 &cfg.model,
261 thinking_budget,
262 reasoning_level,
263 );
264 let body = Value::Object(body);
265
266 tracing::debug!(provider=%cfg.provider, model=%cfg.model, "openai stream request via broker proxy");
267
268 let (proxy_request, body_bytes) = crate::auth::ProxyRequest::post_json_exact(
274 cfg.provider.clone(),
275 "/chat/completions",
276 body,
277 true,
278 )?;
279 let tracer = tr::begin_openai_tracer(
283 trace,
284 &cfg.provider,
285 &cfg.model,
286 if exact_wire_bytes {
287 crate::runtime::trace::TransportKind::OpenAiChatCompletions
288 } else {
289 crate::runtime::trace::TransportKind::CloudProxy
290 },
291 &format!("{}/chat/completions", cfg.base_url.trim_end_matches('/')),
292 exact_wire_bytes.then_some(body_bytes.as_ref()),
293 messages,
294 system_prompt.as_deref(),
295 tools_schema,
296 tr::renamed_tool_losses(&name_map),
297 )
298 .await;
299 if let Some(tracer) = &tracer {
305 trace.capture_request_content(tracer.request_id(), body_bytes.as_ref());
306 }
307 let mut attempt = tr::StreamAttempt::new(tracer);
308
309 let stream = broker
312 .proxy_stream(proxy_request)
313 .await
314 .map_err(|e| {
317 format!(
318 "openai request failed: {}",
319 super::net::redact_provider_proxy_error(&e.to_string())
320 )
321 });
322 let mut stream = match stream {
323 Ok(stream) => {
324 attempt.mark_headers();
325 stream
326 }
327 Err(msg) => {
328 let status = tr::broker_error_status(&msg);
331 let code = status.map_or_else(|| "broker_error".to_string(), |s| format!("http_{s}"));
332 attempt.finish_failed(&code, status, None);
333 return Err(msg.into());
334 }
335 };
336
337 let mut decoder = StreamDecoder::new();
338 let mut accumulated_text = String::new();
339 let mut tool_use_blocks: Vec<Value> = Vec::new();
340 let mut buf = bytes::BytesMut::with_capacity(8 * 1024);
341 let mut sink: Vec<OaiEvent> = Vec::with_capacity(4);
342 let mut trace_usage: Option<crate::runtime::trace::UsageMeta> = None;
343
344 while let Some(chunk) = tokio::select! {
345 chunk = stream.next() => chunk,
346 _ = cancel.cancelled() => {
347 attempt.finish_canceled(None, trace_usage);
348 return Err("request canceled".into());
349 }
350 } {
351 let chunk = match chunk {
352 Ok(chunk) => {
353 attempt.mark_first_byte();
354 chunk
355 }
356 Err(e) => {
357 attempt.finish_failed("stream_error", None, None);
358 return Err(e.into());
359 }
360 };
361 buf.extend_from_slice(&chunk);
362
363 while let Some(nl) = memchr::memchr(b'\n', &buf) {
365 let line_bytes = buf.split_to(nl + 1); let line = std::str::from_utf8(&line_bytes[..nl]).unwrap_or("");
367
368 sink.clear();
369 decoder.push_line(line, &mut sink);
370 capture_oai_trace_signals(&sink, &mut attempt, &mut trace_usage);
371 handle_events(
372 &sink,
373 tx,
374 &mut accumulated_text,
375 &mut tool_use_blocks,
376 &name_map,
377 suppress_stream_deltas,
378 );
379 }
380 }
381
382 if !buf.is_empty() {
384 let line = std::str::from_utf8(&buf).unwrap_or("");
385 sink.clear();
386 decoder.push_line(line, &mut sink);
387 capture_oai_trace_signals(&sink, &mut attempt, &mut trace_usage);
388 handle_events(
389 &sink,
390 tx,
391 &mut accumulated_text,
392 &mut tool_use_blocks,
393 &name_map,
394 suppress_stream_deltas,
395 );
396 }
397 sink.clear();
398 decoder.finish(&mut sink);
399 capture_oai_trace_signals(&sink, &mut attempt, &mut trace_usage);
400 handle_events(
401 &sink,
402 tx,
403 &mut accumulated_text,
404 &mut tool_use_blocks,
405 &name_map,
406 suppress_stream_deltas,
407 );
408
409 let stop_reason = decoder
412 .finish_reason
413 .as_deref()
414 .map(tr::stop_reason_from_finish_reason);
415 attempt.finish_success(None, None, stop_reason, trace_usage);
417
418 let mut content: Vec<Value> = Vec::new();
420 if !accumulated_text.is_empty() {
421 content.push(json!({"type": "text", "text": accumulated_text}));
422 }
423 content.extend(tool_use_blocks);
424
425 Ok(json!({
426 "role": "assistant",
427 "content": content,
428 }))
429}
430
431#[allow(clippy::too_many_arguments)]
432pub(crate) async fn call_codex_stream_inner(
433 cfg: &ProviderConfig,
434 client: &reqwest::Client,
435 broker: &std::sync::Arc<dyn crate::auth::CredentialBroker>,
436 tools_schema: &[Value],
437 system_prompt: &Option<String>,
438 messages: &[crate::SharedMessage],
439 tx: &mpsc::UnboundedSender<StreamEvent>,
440 temperature: Option<f32>,
441 max_tokens: Option<u32>,
442 reasoning_level: agent_core::reasoning::ReasoningLevel,
443 codex_request_role: crate::runtime::openai::catalog::CodexRequestRole,
444 cancel: &tokio_util::sync::CancellationToken,
445 max_retries: u32,
446 trace: &crate::runtime::trace::TraceContext,
447) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
448 use crate::runtime::openai::catalog::plan_codex_execution;
451 let qualified_model = format!("{}/{}", cfg.provider, cfg.model);
452 let plan =
453 match plan_codex_execution(&qualified_model, reasoning_level, codex_request_role, None) {
454 Ok(plan) => plan,
455 Err(error) => {
456 tracing::debug!(
457 event = "codex_mode_plan",
458 qualified_model = %qualified_model,
459 requested_level = %reasoning_level,
460 runtime_role = codex_request_role.as_str(),
461 decision = "deny",
462 deny_code = error.code().as_str(),
463 network_attempted = false,
464 "Codex execution plan denied"
465 );
466 return Err(Box::new(error));
467 }
468 };
469 if plan.automatic_delegation() && !codex_has_required_delegation_tools(tools_schema) {
470 tracing::debug!(
471 event = "codex_mode_plan",
472 qualified_model = %qualified_model,
473 requested_level = %reasoning_level,
474 runtime_role = codex_request_role.as_str(),
475 decision = "deny",
476 deny_code = "ultra_requires_subagent_tools",
477 network_attempted = false,
478 "Codex execution plan denied"
479 );
480 return Err(
481 "Ultra requires subagent_start, subagent_status, and subagent_collect tools".into(),
482 );
483 }
484 tracing::debug!(
485 event = "codex_mode_plan",
486 qualified_model = %plan.qualified_model,
487 requested_level = %plan.selected_level,
488 execution_mode = plan.mode.as_str(),
489 wire_effort = plan.wire_effort_label(),
490 capability_source = plan.capability_source.map_or("none", |source| source.as_str()),
491 multi_agent_version = plan.multi_agent_version_label(),
492 runtime_role = plan.request_role.as_str(),
493 multi_agent_mode = plan.multi_agent_mode_label(),
494 automatic_delegation = plan.automatic_delegation(),
495 decision = "allow",
496 network_attempted = false,
497 "Codex execution plan allowed"
498 );
499 let access = broker
503 .access_token(crate::auth::OAuthProviderId::OpenAiCodex)
504 .await
505 .map_err(|e| e.to_string())?
506 .token;
507 let account_id = crate::auth::extract_codex_account_id(&access)
509 .ok_or("Failed to extract ChatGPT account id from Codex token — run `synaps login --provider openai-codex`")?;
510
511 let (oai_tools, name_map) = translate::tools_to_oai(tools_schema);
512 let oai_messages = translate::messages_to_oai(messages, system_prompt, &name_map);
513 let tools: Vec<Value> = oai_tools
514 .into_iter()
515 .map(|tool| {
516 json!({
517 "type": "function",
518 "name": tool.function.name,
519 "description": tool.function.description.unwrap_or_default(),
520 "parameters": tool.function.parameters,
521 })
522 })
523 .collect();
524
525 let instructions = codex_instructions(system_prompt);
528 let mut input_items = codex_input_messages(oai_messages);
529 let prompt_cache_key = codex_prompt_cache_key(&instructions, input_items.first());
532 insert_codex_multi_agent_mode(&mut input_items, &plan);
533 let input = serde_json::Value::Array(input_items);
534 let body = build_codex_body(
535 &cfg.model,
536 &plan,
537 input,
538 instructions,
539 tools,
540 temperature,
541 max_tokens,
542 &prompt_cache_key,
543 );
544
545 let url = format!(
546 "{}/codex/responses",
547 cfg.base_url
548 .trim_end_matches('/')
549 .trim_end_matches("/codex")
550 );
551 tracing::debug!(url=%url, model=%cfg.model, "codex stream request");
552
553 let body_bytes = bytes::Bytes::from(serde_json::to_vec(&body)?);
557 let tracer = tr::begin_openai_tracer(
559 trace,
560 &cfg.provider,
561 &cfg.model,
562 crate::runtime::trace::TransportKind::OpenAiResponses,
563 &url,
564 Some(body_bytes.as_ref()),
565 messages,
566 system_prompt.as_deref(),
567 tools_schema,
568 tr::renamed_tool_losses(&name_map),
569 )
570 .await;
571 if let Some(tracer) = &tracer {
577 trace.capture_request_content(tracer.request_id(), body_bytes.as_ref());
578 }
579 let mut attempt = tr::StreamAttempt::new(tracer);
580 let mut stream_retry = 0u32;
581
582 loop {
583 let resp = send_with_retries(
584 "codex",
585 &url,
586 || {
587 client
588 .post(&url)
589 .bearer_auth(&access)
590 .header("chatgpt-account-id", account_id.as_str())
591 .header("originator", "synaps")
592 .header("OpenAI-Beta", "responses=experimental")
593 .header("content-type", "application/json")
594 .header("accept", "text/event-stream")
595 .body(body_bytes.clone())
596 },
597 cancel,
598 max_retries.saturating_sub(stream_retry),
599 &mut attempt,
600 )
601 .await?;
602 let http_status = Some(resp.status().as_u16());
604 let trace_rid = tr::provider_request_id_from_headers(resp.headers());
605
606 let mut accumulated_text = String::new();
607 let mut parser = CodexSseDecoder::default();
608 let mut buf = bytes::BytesMut::with_capacity(8 * 1024);
609 let mut stream = resp.bytes_stream();
610
611 while let Some(chunk) = tokio::select! {
612 chunk = stream.next() => chunk,
613 _ = cancel.cancelled() => {
614 attempt.finish_canceled(http_status, parser.trace_usage());
615 return Err("request canceled".into());
616 }
617 } {
618 let chunk = match chunk {
619 Ok(chunk) => {
620 attempt.mark_first_byte();
621 chunk
622 }
623 Err(e) => {
624 attempt.finish_failed("stream_error", http_status, trace_rid.clone());
625 return Err(e.into());
626 }
627 };
628 buf.extend_from_slice(&chunk);
629 while let Some(nl) = memchr::memchr(b'\n', &buf) {
630 let line_bytes = buf.split_to(nl + 1);
631 let line = std::str::from_utf8(&line_bytes[..nl]).unwrap_or("");
632 parser.push_line(line, tx, &mut accumulated_text);
633 }
634 if parser.saw_model_event {
635 attempt.mark_first_model_event();
636 }
637 }
638 if !buf.is_empty() {
639 let line = std::str::from_utf8(&buf).unwrap_or("");
640 parser.push_line(line, tx, &mut accumulated_text);
641 }
642 parser.finish();
643 if parser.saw_model_event {
647 attempt.mark_first_model_event();
648 }
649 if let Err(failure) = parser.terminal_result() {
650 if failure.code == "responses_empty" && stream_retry < max_retries {
651 stream_retry += 1;
652 let delay = retry_delay(stream_retry);
653 attempt.attempt_failed(
654 crate::runtime::trace::RetryClass::Other,
655 delay,
656 http_status,
657 trace_rid,
658 failure.code,
659 );
660 tracing::warn!(
661 "codex stream retry {stream_retry}/{max_retries} after {delay:?}: completed without usable output"
662 );
663 tokio::select! {
664 _ = tokio::time::sleep(delay) => {}
665 _ = cancel.cancelled() => {
666 attempt.finish_canceled(None, None);
667 return Err("request canceled".into());
668 }
669 }
670 attempt.restart_clock();
671 continue;
672 }
673 attempt.finish_failed(failure.code, http_status, trace_rid);
674 return Err(failure.message.into());
675 }
676 attempt.finish_success(http_status, trace_rid, None, parser.trace_usage());
677
678 let mut content: Vec<Value> = Vec::new();
679 if !accumulated_text.is_empty() {
680 content.push(json!({"type": "text", "text": accumulated_text}));
681 }
682 content.extend(translate::tool_calls_to_content_blocks(
683 &parser.completed_tools,
684 &name_map,
685 ));
686
687 return Ok(json!({
688 "role": "assistant",
689 "content": content,
690 }));
691 }
692}
693
694pub(crate) fn codex_prompt_cache_key(instructions: &str, first_item: Option<&Value>) -> String {
706 use sha2::{Digest, Sha256};
707 let mut hasher = Sha256::new();
708 hasher.update(instructions.as_bytes());
709 if let Some(item) = first_item {
710 hasher.update(item.to_string().as_bytes());
711 }
712 format!("synaps-{:x}", hasher.finalize())
713}
714
715#[allow(clippy::too_many_arguments)] pub(crate) fn build_codex_body(
732 model: &str,
733 plan: &crate::runtime::openai::catalog::CodexExecutionPlan,
734 input: serde_json::Value,
735 instructions: String,
736 tools: Vec<serde_json::Value>,
737 temperature: Option<f32>,
738 max_tokens: Option<u32>,
739 prompt_cache_key: &str,
740) -> serde_json::Value {
741 let mut body = json!({
742 "model": model,
743 "store": false,
744 "stream": true,
745 "instructions": instructions,
746 "input": input,
747 "tool_choice": "auto",
748 "parallel_tool_calls": true,
749 "include": ["reasoning.encrypted_content"],
750 "text": { "verbosity": "medium" },
751 "prompt_cache_key": prompt_cache_key,
752 });
753 if let Some(effort) = plan.wire_effort {
754 body["reasoning"] = json!({ "effort": effort.as_str() });
755 }
756 if !tools.is_empty() {
757 body["tools"] = serde_json::Value::Array(tools);
758 }
759 if let Some(temp) = temperature {
760 body["temperature"] = json!(temp);
761 }
762 if let Some(max) = max_tokens {
763 body["max_output_tokens"] = json!(max);
764 }
765 body
766}
767
768const CODEX_MULTI_AGENT_MODE_OPEN: &str = "<multi_agent_mode>";
769const CODEX_MULTI_AGENT_MODE_CLOSE: &str = "</multi_agent_mode>";
770const 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.";
771const 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.";
772
773fn codex_multi_agent_mode_item(
774 plan: &crate::runtime::openai::catalog::CodexExecutionPlan,
775) -> Option<Value> {
776 use crate::runtime::openai::catalog::CodexMultiAgentMode;
777 let body = match plan.multi_agent_mode? {
778 CodexMultiAgentMode::ExplicitRequestOnly => CODEX_EXPLICIT_REQUEST_ONLY_TEXT,
779 CodexMultiAgentMode::Proactive => CODEX_PROACTIVE_MULTI_AGENT_TEXT,
780 };
781 Some(json!({
782 "type": "message",
783 "role": "developer",
784 "content": [{
785 "type": "input_text",
786 "text": format!("{CODEX_MULTI_AGENT_MODE_OPEN}{body}{CODEX_MULTI_AGENT_MODE_CLOSE}"),
787 }],
788 }))
789}
790
791fn is_codex_multi_agent_mode_item(item: &Value) -> bool {
792 item.pointer("/content/0/text")
793 .and_then(Value::as_str)
794 .is_some_and(|text| {
795 text.trim_start().starts_with(CODEX_MULTI_AGENT_MODE_OPEN)
796 && text.trim_end().ends_with(CODEX_MULTI_AGENT_MODE_CLOSE)
797 })
798}
799
800fn insert_codex_multi_agent_mode(
801 input: &mut Vec<Value>,
802 plan: &crate::runtime::openai::catalog::CodexExecutionPlan,
803) {
804 input.retain(|item| !is_codex_multi_agent_mode_item(item));
805 let Some(item) = codex_multi_agent_mode_item(plan) else {
806 return;
807 };
808 input.insert(0, item);
818}
819
820fn codex_has_required_delegation_tools(tools_schema: &[Value]) -> bool {
821 ["subagent_start", "subagent_status", "subagent_collect"]
822 .into_iter()
823 .all(|required| {
824 tools_schema.iter().any(|tool| {
825 tool.get("name").and_then(Value::as_str) == Some(required)
826 || tool.pointer("/function/name").and_then(Value::as_str) == Some(required)
827 })
828 })
829}
830
831const 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]";
832
833fn codex_instructions(system_prompt: &Option<String>) -> String {
834 let mut instructions = system_prompt.clone().unwrap_or_default();
835 if instructions.contains("[Synaps autonomous harness policy]") {
836 return instructions;
837 }
838 instructions.push_str(CODEX_AUTONOMOUS_LOOP_POLICY);
839 instructions
840}
841
842fn codex_input_messages(messages: Vec<ChatMessage>) -> Vec<Value> {
843 let mut out = Vec::new();
844 for msg in messages {
845 if let Some(tool_calls) = msg.tool_calls {
846 for call in tool_calls {
847 let mut item = json!({
854 "type": "function_call",
855 "call_id": call.id,
856 "name": call.function.name,
857 "arguments": call.function.arguments,
858 });
859 if call.id.starts_with("fc") {
860 item["id"] = Value::from(call.id.clone());
861 }
862 out.push(item);
863 }
864 continue;
865 }
866 if msg.role == "tool" {
867 if let Some(call_id) = msg.tool_call_id {
870 out.push(json!({
871 "type": "function_call_output",
872 "call_id": call_id,
873 "output": msg.content.unwrap_or_default(),
874 }));
875 }
876 continue;
877 }
878 out.push(json!({
879 "role": msg.role,
880 "content": msg.content.unwrap_or_default(),
881 }));
882 }
883 out
884}
885
886#[derive(Clone, Debug, Eq, PartialEq)]
887struct ResponsesStreamFailure {
888 code: &'static str,
889 message: String,
892}
893
894impl ResponsesStreamFailure {
895 fn is_retryable(&self) -> bool {
899 matches!(
900 self.code,
901 "responses_capacity" | "responses_empty" | "responses_missing_terminal"
902 )
903 }
904}
905
906const CAPACITY_ERROR_CODES: &[&str] = &[
910 "rate_limit_exceeded",
911 "server_overloaded",
912 "overloaded",
913 "capacity",
914 "server_error",
915];
916
917const CAPACITY_MESSAGE_PATTERNS: &[&str] = &[
923 "at capacity",
924 "high demand",
925 "try again in a few minutes",
926 "overloaded",
927];
928
929fn is_capacity_failure(error_code: &str, message: Option<&str>) -> bool {
932 if CAPACITY_ERROR_CODES.contains(&error_code) {
933 return true;
934 }
935 message.is_some_and(|m| {
936 let lower = m.to_ascii_lowercase();
937 CAPACITY_MESSAGE_PATTERNS.iter().any(|p| lower.contains(p))
938 })
939}
940
941struct CodexSseDecoder {
942 provider_label: &'static str,
945 buffer: String,
946 active_tools: Vec<CodexToolAccumulator>,
947 completed_tools: Vec<ToolCall>,
948 saw_model_event: bool,
951 terminal_success: bool,
955 terminal_failure: Option<ResponsesStreamFailure>,
956 emitted_output: bool,
957 observed_usage: Option<(u64, u64, u64)>,
960}
961
962impl Default for CodexSseDecoder {
963 fn default() -> Self {
964 Self::for_provider("Codex")
965 }
966}
967
968#[derive(Default)]
969struct CodexToolAccumulator {
970 id: String,
971 name: String,
972 arguments: String,
973 started: bool,
974}
975
976fn parse_tool_arguments(raw: &str) -> Value {
984 if raw.trim().is_empty() {
985 return json!({});
986 }
987 match serde_json::from_str(raw) {
988 Ok(v) => v,
989 Err(e) => json!({ "__parse_error": format!("invalid tool input JSON: {}", e) }),
990 }
991}
992
993fn sanitize_error_identifier(raw: Option<&str>) -> &str {
1002 match raw {
1003 None => "absent",
1004 Some(s) => {
1005 let identifier_shaped = !s.is_empty()
1006 && s.len() <= 64
1007 && s.bytes()
1008 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.');
1009 if identifier_shaped
1010 && s.bytes()
1011 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
1012 {
1013 s
1015 } else if identifier_shaped {
1016 "unlisted_identifier"
1019 } else {
1020 "unsafe_or_freeform"
1021 }
1022 }
1023 }
1024}
1025
1026pub(crate) const RESPONSES_FAILED_SUFFIX: &str =
1030 " response failed in stream. Provider error details withheld because they can echo request content.";
1031pub(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.";
1032pub(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.";
1033pub(crate) const RESPONSES_INCOMPLETE_SUFFIX: &str =
1034 " response was incomplete. Retry the request or reduce the requested output/context size.";
1035pub(crate) const RESPONSES_EMPTY_SUFFIX: &str =
1036 " completed without text or tool output. Retry the request.";
1037pub(crate) const RESPONSES_MISSING_TERMINAL_SUFFIX: &str =
1038 " response stream ended without a terminal event. Retry the request.";
1039
1040fn user_message_for_terminal_failure(
1046 label: &str,
1047 error_kind: &str,
1048 error_code: &str,
1049 capacity: bool,
1050) -> String {
1051 if error_code == "context_length_exceeded" || error_kind == "context_length_exceeded" {
1054 return format!("{label}{RESPONSES_CONTEXT_SUFFIX}");
1055 }
1056 if capacity {
1057 return format!("{label}{RESPONSES_CAPACITY_SUFFIX}");
1058 }
1059 format!("{label}{RESPONSES_FAILED_SUFFIX}")
1060}
1061
1062impl CodexSseDecoder {
1063 fn for_provider(label: &'static str) -> Self {
1065 Self {
1066 provider_label: label,
1067 buffer: String::new(),
1068 active_tools: Vec::new(),
1069 completed_tools: Vec::new(),
1070 saw_model_event: false,
1071 terminal_success: false,
1072 terminal_failure: None,
1073 emitted_output: false,
1074 observed_usage: None,
1075 }
1076 }
1077
1078 fn emitted_any_output(&self) -> bool {
1082 self.emitted_output
1083 || !self.completed_tools.is_empty()
1084 || self.active_tools.iter().any(|tool| tool.started)
1085 }
1086
1087 fn push_line(
1088 &mut self,
1089 line: &str,
1090 tx: &mpsc::UnboundedSender<StreamEvent>,
1091 text_acc: &mut String,
1092 ) {
1093 let line = line.trim_end_matches('\r');
1094 if line.is_empty() {
1095 if !self.buffer.is_empty() {
1096 let payload = std::mem::take(&mut self.buffer);
1097 self.push_payload(&payload, tx, text_acc);
1098 }
1099 return;
1100 }
1101 let Some(data) = line.strip_prefix("data:").map(str::trim_start) else {
1102 return;
1103 };
1104 if data == "[DONE]" {
1105 self.terminal_success = true;
1106 self.finish();
1107 return;
1108 }
1109 self.buffer.push_str(data);
1110 }
1111
1112 fn push_payload(
1113 &mut self,
1114 payload: &str,
1115 tx: &mpsc::UnboundedSender<StreamEvent>,
1116 text_acc: &mut String,
1117 ) {
1118 let Ok(event) = serde_json::from_str::<Value>(payload) else {
1119 tracing::debug!(
1120 payload_bytes = payload.len(),
1121 "discarding malformed Responses SSE payload"
1122 );
1123 return;
1124 };
1125 let event_type = event
1126 .get("type")
1127 .and_then(Value::as_str)
1128 .unwrap_or_default();
1129 if matches!(
1130 event_type,
1131 "response.output_text.delta"
1132 | "response.output_item.added"
1133 | "response.function_call_arguments.delta"
1134 | "response.output_item.done"
1135 | "response.completed"
1136 | "response.done"
1137 | "response.failed"
1138 | "response.incomplete"
1139 | "error"
1140 ) {
1141 self.saw_model_event = true;
1142 }
1143 match event_type {
1144 "response.output_text.delta" => {
1145 if let Some(delta) = event.get("delta").and_then(Value::as_str) {
1146 if !delta.is_empty() {
1147 self.emitted_output = true;
1148 }
1149 text_acc.push_str(delta);
1150 let _ = tx.send(StreamEvent::Llm(crate::runtime::types::LlmEvent::Text(
1151 delta.to_string(),
1152 )));
1153 }
1154 }
1155 "response.output_item.added" => {
1156 if let Some(item) = event.get("item") {
1157 let idx = event
1158 .get("output_index")
1159 .and_then(Value::as_u64)
1160 .unwrap_or(0) as usize;
1161 self.add_tool_from_item(idx, item, tx);
1162 }
1163 }
1164 "response.function_call_arguments.delta" => {
1165 let idx = event
1166 .get("output_index")
1167 .and_then(Value::as_u64)
1168 .unwrap_or(0) as usize;
1169 let delta = event
1170 .get("delta")
1171 .and_then(Value::as_str)
1172 .unwrap_or_default();
1173 if !delta.is_empty() {
1174 let tool = self.ensure_tool(idx);
1175 tool.arguments.push_str(delta);
1176 let tool_id = tool.id.clone();
1177 let _ = tx.send(StreamEvent::Llm(
1178 crate::runtime::types::LlmEvent::ToolUseDelta {
1179 tool_id,
1180 delta: delta.to_string(),
1181 },
1182 ));
1183 }
1184 }
1185 "response.output_item.done" => {
1186 if let Some(item) = event.get("item") {
1187 let idx = event
1188 .get("output_index")
1189 .and_then(Value::as_u64)
1190 .unwrap_or(0) as usize;
1191 self.complete_tool_from_item(idx, item, tx);
1192 }
1193 }
1194 "response.completed" | "response.done" => {
1195 self.push_usage(&event, tx);
1196 self.terminal_success = true;
1197 self.finish();
1198 }
1199 "response.failed" | "error" => {
1200 self.push_usage(&event, tx);
1201 let error_obj = event
1212 .get("response")
1213 .and_then(|r| r.get("error"))
1214 .or_else(|| event.get("error"))
1215 .filter(|e| e.is_object());
1216 let error_kind = sanitize_error_identifier(
1217 error_obj
1218 .and_then(|e| e.get("type"))
1219 .and_then(Value::as_str),
1220 );
1221 let error_code = sanitize_error_identifier(
1222 error_obj
1223 .and_then(|e| e.get("code"))
1224 .or_else(|| event.get("code"))
1225 .and_then(Value::as_str),
1226 );
1227 let capacity = is_capacity_failure(
1230 error_code,
1231 error_obj
1232 .and_then(|e| e.get("message"))
1233 .or_else(|| event.get("message"))
1234 .and_then(Value::as_str),
1235 );
1236 tracing::warn!(
1237 provider = self.provider_label,
1238 event_type,
1239 error_kind,
1240 error_code,
1241 capacity,
1242 "responses stream terminal failure (provider message withheld)"
1243 );
1244 self.terminal_failure = Some(ResponsesStreamFailure {
1245 code: if capacity {
1246 "responses_capacity"
1247 } else {
1248 "responses_failed"
1249 },
1250 message: user_message_for_terminal_failure(
1251 self.provider_label,
1252 error_kind,
1253 error_code,
1254 capacity,
1255 ),
1256 });
1257 self.finish();
1258 }
1259 "response.incomplete" => {
1260 self.push_usage(&event, tx);
1261 let reason = sanitize_error_identifier(
1262 event
1263 .get("response")
1264 .and_then(|r| r.get("incomplete_details"))
1265 .and_then(|d| d.get("reason"))
1266 .and_then(Value::as_str),
1267 );
1268 tracing::warn!(
1269 provider = self.provider_label,
1270 reason,
1271 "responses stream terminal incomplete"
1272 );
1273 self.terminal_failure = Some(ResponsesStreamFailure {
1274 code: "responses_incomplete",
1275 message: format!("{}{RESPONSES_INCOMPLETE_SUFFIX}", self.provider_label),
1276 });
1277 self.finish();
1278 }
1279 _ => {
1280 if !event_type.is_empty() {
1281 tracing::debug!(event_type, "ignoring unknown Responses SSE event");
1282 }
1283 }
1284 }
1285 }
1286
1287 fn terminal_result(&self) -> Result<(), ResponsesStreamFailure> {
1288 if let Some(failure) = &self.terminal_failure {
1289 return Err(failure.clone());
1290 }
1291 if self.terminal_success {
1292 if self.emitted_output || !self.completed_tools.is_empty() {
1293 Ok(())
1294 } else {
1295 Err(ResponsesStreamFailure {
1296 code: "responses_empty",
1297 message: format!("{}{RESPONSES_EMPTY_SUFFIX}", self.provider_label),
1298 })
1299 }
1300 } else {
1301 Err(ResponsesStreamFailure {
1302 code: "responses_missing_terminal",
1303 message: format!("{}{RESPONSES_MISSING_TERMINAL_SUFFIX}", self.provider_label),
1304 })
1305 }
1306 }
1307
1308 fn ensure_tool(&mut self, idx: usize) -> &mut CodexToolAccumulator {
1309 while self.active_tools.len() <= idx {
1310 self.active_tools.push(CodexToolAccumulator::default());
1311 }
1312 &mut self.active_tools[idx]
1313 }
1314
1315 fn add_tool_from_item(
1316 &mut self,
1317 idx: usize,
1318 item: &Value,
1319 tx: &mpsc::UnboundedSender<StreamEvent>,
1320 ) {
1321 if item.get("type").and_then(Value::as_str) != Some("function_call") {
1322 return;
1323 }
1324 let tool = self.ensure_tool(idx);
1325 if let Some(id) = item
1326 .get("call_id")
1327 .or_else(|| item.get("id"))
1328 .and_then(Value::as_str)
1329 {
1330 tool.id = id.to_string();
1331 }
1332 if let Some(name) = item.get("name").and_then(Value::as_str) {
1333 tool.name = name.to_string();
1334 }
1335 if !tool.started && !tool.name.is_empty() {
1336 tool.started = true;
1337 let _ = tx.send(StreamEvent::Llm(
1338 crate::runtime::types::LlmEvent::ToolUseStart {
1339 tool_name: tool.name.clone(),
1340 tool_id: tool.id.clone(),
1341 },
1342 ));
1343 }
1344 }
1345
1346 fn complete_tool_from_item(
1347 &mut self,
1348 idx: usize,
1349 item: &Value,
1350 tx: &mpsc::UnboundedSender<StreamEvent>,
1351 ) {
1352 if item.get("type").and_then(Value::as_str) != Some("function_call") {
1353 return;
1354 }
1355 let tool = self.ensure_tool(idx);
1356 if let Some(id) = item
1357 .get("call_id")
1358 .or_else(|| item.get("id"))
1359 .and_then(Value::as_str)
1360 {
1361 tool.id = id.to_string();
1362 }
1363 if let Some(name) = item.get("name").and_then(Value::as_str) {
1364 tool.name = name.to_string();
1365 }
1366 if let Some(arguments) = item.get("arguments").and_then(Value::as_str) {
1367 tool.arguments = arguments.to_string();
1368 }
1369 if !tool.started && !tool.name.is_empty() {
1370 tool.started = true;
1371 let _ = tx.send(StreamEvent::Llm(
1372 crate::runtime::types::LlmEvent::ToolUseStart {
1373 tool_name: tool.name.clone(),
1374 tool_id: tool.id.clone(),
1375 },
1376 ));
1377 }
1378 let completed = if !tool.id.is_empty() && !tool.name.is_empty() {
1379 Some(ToolCall {
1380 id: tool.id.clone(),
1381 kind: "function".to_string(),
1382 function: super::types::FunctionCall {
1383 name: tool.name.clone(),
1384 arguments: tool.arguments.clone(),
1385 },
1386 })
1387 } else {
1388 None
1389 };
1390 if let Some(call) = completed {
1391 if self.completed_tools.iter().any(|done| done.id == call.id) {
1392 return;
1393 }
1394 self.emitted_output = true;
1395 let input = parse_tool_arguments(&call.function.arguments);
1403 let _ = tx.send(StreamEvent::Llm(crate::runtime::types::LlmEvent::ToolUse {
1404 tool_name: call.function.name.clone(),
1405 tool_id: call.id.clone(),
1406 input,
1407 }));
1408 self.completed_tools.push(ToolCall {
1409 id: call.id,
1410 kind: call.kind,
1411 function: call.function,
1412 });
1413 }
1414 }
1415
1416 fn push_usage(&mut self, event: &Value, tx: &mpsc::UnboundedSender<StreamEvent>) {
1417 let usage = event
1418 .get("response")
1419 .and_then(|r| r.get("usage"))
1420 .or_else(|| event.get("usage"));
1421 let input = usage
1422 .and_then(|u| u.get("input_tokens"))
1423 .and_then(Value::as_u64)
1424 .unwrap_or(0);
1425 let output = usage
1426 .and_then(|u| u.get("output_tokens"))
1427 .and_then(Value::as_u64)
1428 .unwrap_or(0);
1429 let cached = usage
1436 .and_then(|u| u.pointer("/input_tokens_details/cached_tokens"))
1437 .and_then(Value::as_u64)
1438 .unwrap_or(0)
1439 .min(input);
1440 if input > 0 || output > 0 {
1441 if self.observed_usage.is_none() {
1444 self.observed_usage = Some((input, output, cached));
1445 }
1446 let _ = tx.send(StreamEvent::Session(
1447 crate::runtime::types::SessionEvent::Usage {
1448 input_tokens: input - cached,
1449 output_tokens: output,
1450 cache_read_input_tokens: cached,
1451 cache_creation_input_tokens: 0,
1452 cache_creation_5m: None,
1453 cache_creation_1h: None,
1454 model: None,
1455 },
1456 ));
1457 }
1458 }
1459
1460 fn trace_usage(&self) -> Option<crate::runtime::trace::UsageMeta> {
1462 self.observed_usage
1463 .map(|(input, output, cached)| tr::provider_usage(input, output, cached))
1464 }
1465
1466 fn finish(&mut self) {
1467 for tool in self.active_tools.drain(..) {
1468 if !tool.id.is_empty()
1469 && !tool.name.is_empty()
1470 && !self.completed_tools.iter().any(|done| done.id == tool.id)
1471 {
1472 self.emitted_output = true;
1473 self.completed_tools.push(ToolCall {
1474 id: tool.id,
1475 kind: "function".to_string(),
1476 function: super::types::FunctionCall {
1477 name: tool.name,
1478 arguments: tool.arguments,
1479 },
1480 });
1481 }
1482 }
1483 }
1484}
1485
1486fn handle_events(
1487 events: &[OaiEvent],
1488 tx: &mpsc::UnboundedSender<StreamEvent>,
1489 text_acc: &mut String,
1490 tool_blocks: &mut Vec<Value>,
1491 name_map: &translate::ToolNameMap,
1492 suppress_stream_deltas: bool,
1493) {
1494 for ev in events {
1495 if let OaiEvent::TextDelta(t) = ev {
1496 text_acc.push_str(t);
1497 }
1498 if let OaiEvent::ToolCallsComplete { calls, .. } = ev {
1499 tool_blocks.extend(translate::tool_calls_to_content_blocks(calls, name_map));
1500 }
1501 if !suppress_stream_deltas {
1502 if let Some(se) = translate::oai_event_to_llm(ev) {
1503 let _ = tx.send(se);
1504 }
1505 }
1506 }
1507}
1508
1509#[cfg(test)]
1510mod codex_input_messages_tests {
1511 use super::super::types::{ChatMessage, FunctionCall, ToolCall};
1527 use super::*;
1528
1529 fn sample_tool_call() -> ToolCall {
1530 ToolCall {
1531 id: "call_nZYquCuGUh8Qs9H51dwHMDgs".to_string(),
1532 kind: "function".to_string(),
1533 function: FunctionCall {
1534 name: "bash".to_string(),
1535 arguments: r#"{"command":"ls"}"#.to_string(),
1536 },
1537 }
1538 }
1539
1540 #[test]
1541 fn codex_instructions_appends_autonomous_loop_policy() {
1542 let instructions = codex_instructions(&Some("Project-specific rules.".to_string()));
1543 assert!(instructions.contains("Project-specific rules."));
1544 assert!(instructions.contains("Do not stop at phase boundaries"));
1545 assert!(instructions.contains("Do not ask the user whether to continue"));
1546 assert!(
1547 instructions.contains("continue autonomously until the full requested job is complete")
1548 );
1549 }
1550
1551 #[test]
1552 fn function_call_input_omits_non_fc_id() {
1553 let messages = vec![ChatMessage::assistant_tool_calls(vec![sample_tool_call()])];
1554 let out = codex_input_messages(messages);
1555 assert_eq!(out.len(), 1, "one tool_call → one input item");
1556 let item = &out[0];
1557 assert_eq!(
1558 item.get("type").and_then(Value::as_str),
1559 Some("function_call")
1560 );
1561 assert!(
1562 item.get("id").is_none(),
1563 "must not echo a non-`fc_` id back; got {:?}",
1564 item.get("id"),
1565 );
1566 assert_eq!(
1567 item.get("call_id").and_then(Value::as_str),
1568 Some("call_nZYquCuGUh8Qs9H51dwHMDgs"),
1569 );
1570 assert_eq!(item.get("name").and_then(Value::as_str), Some("bash"));
1571 }
1572
1573 #[test]
1574 fn function_call_input_keeps_real_fc_id() {
1575 let mut call = sample_tool_call();
1578 call.id = "fc_abc123".to_string();
1579 let messages = vec![ChatMessage::assistant_tool_calls(vec![call])];
1580 let out = codex_input_messages(messages);
1581 let item = &out[0];
1582 assert_eq!(item.get("id").and_then(Value::as_str), Some("fc_abc123"));
1583 assert_eq!(
1584 item.get("call_id").and_then(Value::as_str),
1585 Some("fc_abc123")
1586 );
1587 }
1588
1589 #[test]
1590 fn function_call_output_round_trips_call_id() {
1591 let messages = vec![ChatMessage::tool_result(
1593 "call_nZYquCuGUh8Qs9H51dwHMDgs",
1594 "bash",
1595 "total 0",
1596 )];
1597 let out = codex_input_messages(messages);
1598 let item = &out[0];
1599 assert_eq!(
1600 item.get("type").and_then(Value::as_str),
1601 Some("function_call_output"),
1602 );
1603 assert_eq!(
1604 item.get("call_id").and_then(Value::as_str),
1605 Some("call_nZYquCuGUh8Qs9H51dwHMDgs"),
1606 );
1607 assert_eq!(item.get("output").and_then(Value::as_str), Some("total 0"));
1608 }
1609}
1610
1611#[cfg(test)]
1612mod codex_decoder_tests {
1613 use super::*;
1620 use crate::runtime::types::{LlmEvent, SessionEvent, StreamEvent};
1621
1622 fn collect_events(rx: &mut mpsc::UnboundedReceiver<StreamEvent>) -> Vec<StreamEvent> {
1623 let mut out = Vec::new();
1624 while let Ok(ev) = rx.try_recv() {
1625 out.push(ev);
1626 }
1627 out
1628 }
1629
1630 fn drive(lines: &[&str]) -> (CodexSseDecoder, String, Vec<StreamEvent>) {
1631 let (tx, mut rx) = mpsc::unbounded_channel();
1632 let mut decoder = CodexSseDecoder::default();
1633 let mut text_acc = String::new();
1634 for line in lines {
1635 decoder.push_line(line, &tx, &mut text_acc);
1636 }
1637 let events = collect_events(&mut rx);
1638 (decoder, text_acc, events)
1639 }
1640
1641 #[test]
1642 fn text_delta_aggregates_into_text_acc_and_emits_text_events() {
1643 let lines = [
1644 r#"data: {"type":"response.output_text.delta","delta":"Hello, "}"#,
1645 "",
1646 r#"data: {"type":"response.output_text.delta","delta":"world!"}"#,
1647 "",
1648 ];
1649 let (_decoder, text_acc, events) = drive(&lines);
1650 assert_eq!(text_acc, "Hello, world!");
1651 let texts: Vec<_> = events
1652 .iter()
1653 .filter_map(|e| match e {
1654 StreamEvent::Llm(LlmEvent::Text(t)) => Some(t.as_str()),
1655 _ => None,
1656 })
1657 .collect();
1658 assert_eq!(texts, vec!["Hello, ", "world!"]);
1659 }
1660
1661 #[test]
1662 fn single_function_call_completes_via_output_item_done() {
1663 let lines = [
1664 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_abc","name":"bash"}}"#,
1665 "",
1666 r#"data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"{\"cmd\""}"#,
1667 "",
1668 r#"data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":":\"ls\"}"}"#,
1669 "",
1670 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_abc","name":"bash","arguments":"{\"cmd\":\"ls\"}"}}"#,
1671 "",
1672 ];
1673 let (decoder, _text, events) = drive(&lines);
1674
1675 assert_eq!(decoder.completed_tools.len(), 1);
1676 let tool = &decoder.completed_tools[0];
1677 assert_eq!(tool.id, "call_abc");
1678 assert_eq!(tool.function.name, "bash");
1679 assert_eq!(tool.function.arguments, r#"{"cmd":"ls"}"#);
1680
1681 let starts: Vec<_> = events
1683 .iter()
1684 .filter_map(|e| match e {
1685 StreamEvent::Llm(LlmEvent::ToolUseStart { tool_name, tool_id }) => {
1686 Some((tool_name.as_str(), tool_id.as_str()))
1687 }
1688 _ => None,
1689 })
1690 .collect();
1691 assert_eq!(
1692 starts,
1693 vec![("bash", "call_abc")],
1694 "exactly one ToolUseStart with correct tool_id"
1695 );
1696
1697 let deltas: Vec<_> = events
1700 .iter()
1701 .filter_map(|e| match e {
1702 StreamEvent::Llm(LlmEvent::ToolUseDelta { tool_id, delta }) => {
1703 Some((tool_id.as_str(), delta.as_str()))
1704 }
1705 _ => None,
1706 })
1707 .collect();
1708 assert_eq!(
1709 deltas,
1710 vec![("call_abc", r#"{"cmd""#), ("call_abc", r#":"ls"}"#)]
1711 );
1712 }
1713
1714 #[test]
1715 fn parallel_tool_calls_indexed_separately() {
1716 let lines = [
1717 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"bash"}}"#,
1718 "",
1719 r#"data: {"type":"response.output_item.added","output_index":1,"item":{"type":"function_call","call_id":"call_2","name":"read"}}"#,
1720 "",
1721 r#"data: {"type":"response.function_call_arguments.delta","output_index":1,"delta":"{\"path\":\"a\"}"}"#,
1722 "",
1723 r#"data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"{\"cmd\":\"ls\"}"}"#,
1724 "",
1725 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"bash","arguments":"{\"cmd\":\"ls\"}"}}"#,
1726 "",
1727 r#"data: {"type":"response.output_item.done","output_index":1,"item":{"type":"function_call","call_id":"call_2","name":"read","arguments":"{\"path\":\"a\"}"}}"#,
1728 "",
1729 ];
1730 let (decoder, _text, _events) = drive(&lines);
1731
1732 assert_eq!(decoder.completed_tools.len(), 2);
1733 let mut by_id: std::collections::BTreeMap<&str, &ToolCall> =
1734 std::collections::BTreeMap::new();
1735 for tool in &decoder.completed_tools {
1736 by_id.insert(tool.id.as_str(), tool);
1737 }
1738 assert_eq!(by_id["call_1"].function.name, "bash");
1739 assert_eq!(by_id["call_1"].function.arguments, r#"{"cmd":"ls"}"#);
1740 assert_eq!(by_id["call_2"].function.name, "read");
1741 assert_eq!(by_id["call_2"].function.arguments, r#"{"path":"a"}"#);
1742 }
1743
1744 #[test]
1745 fn output_item_done_emits_tool_use_event() {
1746 let lines = [
1753 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_abc","name":"bash"}}"#,
1754 "",
1755 r#"data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"{\"command\":\"ls\"}"}"#,
1756 "",
1757 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_abc","name":"bash","arguments":"{\"command\":\"ls\"}"}}"#,
1758 "",
1759 ];
1760 let (_decoder, _text, events) = drive(&lines);
1761
1762 let tool_uses: Vec<_> = events
1763 .iter()
1764 .filter_map(|e| match e {
1765 StreamEvent::Llm(LlmEvent::ToolUse {
1766 tool_name,
1767 tool_id,
1768 input,
1769 }) => Some((tool_name.as_str(), tool_id.as_str(), input.clone())),
1770 _ => None,
1771 })
1772 .collect();
1773 assert_eq!(
1774 tool_uses.len(),
1775 1,
1776 "expected exactly one ToolUse finalize event"
1777 );
1778 assert_eq!(tool_uses[0].0, "bash");
1779 assert_eq!(tool_uses[0].1, "call_abc");
1780 assert_eq!(
1781 tool_uses[0].2,
1782 serde_json::json!({"command": "ls"}),
1783 "input must be parsed as a JSON Value, not a string"
1784 );
1785 }
1786
1787 #[test]
1788 fn parallel_tool_calls_emit_tool_use_per_index() {
1789 let lines = [
1793 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"bash"}}"#,
1794 "",
1795 r#"data: {"type":"response.output_item.added","output_index":1,"item":{"type":"function_call","call_id":"call_2","name":"read"}}"#,
1796 "",
1797 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"bash","arguments":"{\"command\":\"ls\"}"}}"#,
1798 "",
1799 r#"data: {"type":"response.output_item.done","output_index":1,"item":{"type":"function_call","call_id":"call_2","name":"read","arguments":"{\"path\":\"a\"}"}}"#,
1800 "",
1801 ];
1802 let (_decoder, _text, events) = drive(&lines);
1803
1804 let tool_uses: Vec<_> = events
1805 .iter()
1806 .filter_map(|e| match e {
1807 StreamEvent::Llm(LlmEvent::ToolUse {
1808 tool_name,
1809 tool_id,
1810 input,
1811 }) => Some((tool_name.clone(), tool_id.clone(), input.clone())),
1812 _ => None,
1813 })
1814 .collect();
1815
1816 assert_eq!(tool_uses.len(), 2, "one ToolUse finalize per parallel call");
1817 let by_id: std::collections::BTreeMap<&str, &(String, String, serde_json::Value)> =
1818 tool_uses.iter().map(|t| (t.1.as_str(), t)).collect();
1819 assert_eq!(by_id["call_1"].0, "bash");
1820 assert_eq!(by_id["call_1"].2, serde_json::json!({"command": "ls"}));
1821 assert_eq!(by_id["call_2"].0, "read");
1822 assert_eq!(by_id["call_2"].2, serde_json::json!({"path": "a"}));
1823 }
1824
1825 #[test]
1826 fn malformed_arguments_emit_tool_use_with_parse_error() {
1827 let lines = [
1832 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_bad","name":"bash"}}"#,
1833 "",
1834 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_bad","name":"bash","arguments":"{not json"}}"#,
1835 "",
1836 ];
1837 let (_decoder, _text, events) = drive(&lines);
1838
1839 let tool_use = events.iter().find_map(|e| match e {
1840 StreamEvent::Llm(LlmEvent::ToolUse { input, .. }) => Some(input.clone()),
1841 _ => None,
1842 });
1843 let input = tool_use.expect("ToolUse event missing");
1844 assert!(
1845 input.get("__parse_error").and_then(Value::as_str).is_some(),
1846 "malformed arguments must surface __parse_error, got {input}"
1847 );
1848 }
1849
1850 #[test]
1851 fn response_completed_emits_usage_event() {
1852 let lines = [
1853 r#"data: {"type":"response.completed","response":{"usage":{"input_tokens":42,"output_tokens":17}}}"#,
1854 "",
1855 ];
1856 let (_decoder, _text, events) = drive(&lines);
1857 let usage = events.iter().find_map(|e| match e {
1858 StreamEvent::Session(SessionEvent::Usage {
1859 input_tokens,
1860 output_tokens,
1861 ..
1862 }) => Some((*input_tokens, *output_tokens)),
1863 _ => None,
1864 });
1865 assert_eq!(usage, Some((42, 17)));
1866 }
1867
1868 #[test]
1874 fn response_completed_surfaces_cached_tokens_without_double_count() {
1875 let lines = [
1876 r#"data: {"type":"response.completed","response":{"usage":{"input_tokens":1000,"input_tokens_details":{"cached_tokens":900},"output_tokens":17}}}"#,
1877 "",
1878 ];
1879 let (_decoder, _text, events) = drive(&lines);
1880 let usage = events.iter().find_map(|e| match e {
1881 StreamEvent::Session(SessionEvent::Usage {
1882 input_tokens,
1883 output_tokens,
1884 cache_read_input_tokens,
1885 ..
1886 }) => Some((*input_tokens, *output_tokens, *cache_read_input_tokens)),
1887 _ => None,
1888 });
1889 assert_eq!(
1890 usage,
1891 Some((100, 17, 900)),
1892 "input must exclude the cached slice; cache_read must carry it"
1893 );
1894 }
1895
1896 #[test]
1897 fn response_completed_with_zero_usage_emits_nothing() {
1898 let lines = [
1899 r#"data: {"type":"response.output_text.delta","delta":"ok"}"#,
1900 "",
1901 r#"data: {"type":"response.completed","response":{"usage":{"input_tokens":0,"output_tokens":0}}}"#,
1902 "",
1903 ];
1904 let (decoder, _text, events) = drive(&lines);
1905 let any_usage = events
1906 .iter()
1907 .any(|e| matches!(e, StreamEvent::Session(SessionEvent::Usage { .. })));
1908 assert!(!any_usage, "zero-token usage should be suppressed");
1909 assert_eq!(decoder.terminal_result(), Ok(()));
1910 }
1911
1912 #[test]
1913 fn response_failed_is_a_terminal_failure_without_retaining_provider_text() {
1914 let lines = [
1915 r#"data: {"type":"response.failed","response":{"error":{"type":"server_error","message":"ECHOED:secret prompt"}}}"#,
1916 "",
1917 ];
1918 let (decoder, text, events) = drive(&lines);
1919 let failure = decoder.terminal_result().expect_err("must fail");
1920 assert_eq!(failure.code, "responses_failed");
1921 assert_eq!(
1922 failure.message,
1923 "Codex response failed in stream. Provider error details withheld because they can echo request content."
1924 );
1925 assert!(!failure.message.contains("ECHOED"));
1926 assert!(text.is_empty());
1927 assert!(events.is_empty());
1928 }
1929
1930 #[test]
1931 fn top_level_error_event_is_a_terminal_failure() {
1932 let lines = [
1933 r#"data: {"type":"error","error":{"type":"server_error","message":"private"}}"#,
1934 "",
1935 ];
1936 let (decoder, _text, _events) = drive(&lines);
1937 assert_eq!(
1938 decoder.terminal_result().expect_err("must fail").code,
1939 "responses_failed"
1940 );
1941 }
1942
1943 #[test]
1944 fn response_incomplete_is_not_misreported_as_empty_success() {
1945 let lines = [
1946 r#"data: {"type":"response.incomplete","response":{"incomplete_details":{"reason":"max_output_tokens"}}}"#,
1947 "",
1948 ];
1949 let (decoder, _text, _events) = drive(&lines);
1950 let failure = decoder.terminal_result().expect_err("must fail");
1951 assert_eq!(failure.code, "responses_incomplete");
1952 assert!(failure.message.contains("incomplete"));
1953 assert!(!failure.message.contains("max_output_tokens"));
1954 }
1955
1956 #[test]
1957 fn completed_without_text_or_tools_fails_at_transport_boundary() {
1958 let lines = [
1959 r#"data: {"type":"response.completed","response":{"usage":{"input_tokens":7,"output_tokens":0}}}"#,
1960 "",
1961 ];
1962 let (decoder, _text, _events) = drive(&lines);
1963 assert_eq!(
1964 decoder.terminal_result().expect_err("must fail").code,
1965 "responses_empty"
1966 );
1967 }
1968
1969 #[test]
1970 fn eof_without_terminal_event_fails_closed() {
1971 let lines = [
1972 r#"data: {"type":"response.created","response":{"id":"resp_123"}}"#,
1973 "",
1974 ];
1975 let (decoder, _text, _events) = drive(&lines);
1976 assert_eq!(
1977 decoder.terminal_result().expect_err("must fail").code,
1978 "responses_missing_terminal"
1979 );
1980 }
1981
1982 #[test]
1986 fn error_identifier_sanitizer_never_passes_freeform_text() {
1987 assert_eq!(sanitize_error_identifier(None), "absent");
1988 assert_eq!(
1989 sanitize_error_identifier(Some("rate_limit_exceeded")),
1990 "rate_limit_exceeded"
1991 );
1992 assert_eq!(
1993 sanitize_error_identifier(Some("context_length_exceeded")),
1994 "context_length_exceeded"
1995 );
1996 assert_eq!(
1997 sanitize_error_identifier(Some("some_new_code2")),
1998 "some_new_code2"
1999 );
2000 assert_eq!(
2002 sanitize_error_identifier(Some("sk-Abc123XYZ")),
2003 "unlisted_identifier"
2004 );
2005 assert_eq!(
2006 sanitize_error_identifier(Some("Server.Error")),
2007 "unlisted_identifier"
2008 );
2009 assert_eq!(
2010 sanitize_error_identifier(Some("ECHOED: user secret prompt")),
2011 "unsafe_or_freeform"
2012 );
2013 assert_eq!(sanitize_error_identifier(Some("")), "unsafe_or_freeform");
2014 let long = "a".repeat(65);
2015 assert_eq!(sanitize_error_identifier(Some(&long)), "unsafe_or_freeform");
2016 }
2017
2018 #[test]
2022 fn terminal_failure_user_message_is_specific_only_for_known_codes() {
2023 assert!(user_message_for_terminal_failure(
2024 "Codex",
2025 "invalid_request_error",
2026 "context_length_exceeded",
2027 false
2028 )
2029 .contains("/compact"));
2030 assert!(user_message_for_terminal_failure(
2032 "Codex",
2033 "context_length_exceeded",
2034 "absent",
2035 false
2036 )
2037 .contains("context window"));
2038 let default = user_message_for_terminal_failure(
2040 "Codex",
2041 "server_error",
2042 "unlisted_identifier",
2043 false,
2044 );
2045 assert!(default.starts_with("Codex response failed in stream."));
2046 assert!(!default.contains("/compact"));
2047 let capacity = user_message_for_terminal_failure("xAI", "absent", "absent", true);
2049 assert_eq!(
2050 capacity,
2051 "xAI reports the model is at capacity (retries exhausted). Try again in a few minutes or switch models with /model."
2052 );
2053 }
2054
2055 #[test]
2060 fn xai_flat_capacity_error_is_retryable_capacity_failure() {
2061 let lines = [
2062 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}"#,
2063 "",
2064 ];
2065 let (tx, mut rx) = mpsc::unbounded_channel();
2066 let mut decoder = CodexSseDecoder::for_provider("xAI");
2067 let mut text = String::new();
2068 for line in lines {
2069 decoder.push_line(line, &tx, &mut text);
2070 }
2071 decoder.finish();
2072 let failure = decoder.terminal_result().expect_err("must fail");
2073 assert_eq!(failure.code, "responses_capacity");
2074 assert!(failure.is_retryable());
2075 assert_eq!(
2076 failure.message,
2077 "xAI reports the model is at capacity (retries exhausted). Try again in a few minutes or switch models with /model."
2078 );
2079 for banned in ["high demand", "docs.x.ai", "service tier", "Codex"] {
2080 assert!(
2081 !failure.message.contains(banned),
2082 "provider text `{banned}` leaked: {}",
2083 failure.message
2084 );
2085 }
2086 assert!(text.is_empty());
2087 assert!(!decoder.emitted_any_output());
2088 assert!(rx.try_recv().is_err(), "no events for an error-only stream");
2089 }
2090
2091 #[test]
2094 fn flat_code_classifies_capacity_only_for_vetted_codes() {
2095 let capacity = [
2096 r#"data: {"type":"error","code":"rate_limit_exceeded","message":"private"}"#,
2097 "",
2098 ];
2099 let (decoder, _, _) = drive(&capacity);
2100 let failure = decoder.terminal_result().expect_err("must fail");
2101 assert_eq!(failure.code, "responses_capacity");
2102 assert!(failure.is_retryable());
2103
2104 let other = [
2105 r#"data: {"type":"error","code":"invalid_api_key","message":"private"}"#,
2106 "",
2107 ];
2108 let (decoder, _, _) = drive(&other);
2109 let failure = decoder.terminal_result().expect_err("must fail");
2110 assert_eq!(failure.code, "responses_failed");
2111 assert!(!failure.is_retryable());
2112 assert!(!failure.message.contains("private"));
2113 }
2114
2115 #[test]
2116 fn provider_label_is_carried_into_every_terminal_message() {
2117 let (tx, _rx) = mpsc::unbounded_channel();
2118 let mut text = String::new();
2119 let mut decoder = CodexSseDecoder::for_provider("xAI");
2120 decoder.push_line(
2121 r#"data: {"type":"response.completed","response":{"usage":{"input_tokens":1,"output_tokens":0}}}"#,
2122 &tx,
2123 &mut text,
2124 );
2125 decoder.push_line("", &tx, &mut text);
2126 let failure = decoder.terminal_result().expect_err("empty");
2127 assert_eq!(failure.code, "responses_empty");
2128 assert!(failure.is_retryable());
2129 assert_eq!(
2130 failure.message,
2131 "xAI completed without text or tool output. Retry the request."
2132 );
2133
2134 let decoder = CodexSseDecoder::for_provider("xAI");
2135 let failure = decoder.terminal_result().expect_err("missing terminal");
2136 assert_eq!(failure.code, "responses_missing_terminal");
2137 assert!(failure.is_retryable());
2138 assert!(failure.message.starts_with("xAI response stream ended"));
2139
2140 let mut decoder = CodexSseDecoder::for_provider("xAI");
2141 decoder.push_line(
2142 r#"data: {"type":"response.incomplete","response":{"incomplete_details":{"reason":"max_output_tokens"}}}"#,
2143 &tx,
2144 &mut text,
2145 );
2146 decoder.push_line("", &tx, &mut text);
2147 let failure = decoder.terminal_result().expect_err("incomplete");
2148 assert_eq!(failure.code, "responses_incomplete");
2149 assert!(!failure.is_retryable());
2150 assert!(failure.message.starts_with("xAI response was incomplete."));
2151 }
2152
2153 #[test]
2154 fn done_marker_finishes_decoder() {
2155 let lines = [
2156 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_x","name":"bash"}}"#,
2157 "",
2158 r#"data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"{}"}"#,
2159 "",
2160 "data: [DONE]",
2161 "",
2162 ];
2163 let (decoder, _text, _events) = drive(&lines);
2164 assert_eq!(decoder.completed_tools.len(), 1);
2166 assert_eq!(decoder.completed_tools[0].id, "call_x");
2167 assert_eq!(decoder.completed_tools[0].function.arguments, "{}");
2168 }
2169
2170 #[test]
2171 fn finish_is_idempotent_no_double_emit() {
2172 let lines = [
2173 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_y","name":"bash","arguments":"{}"}}"#,
2174 "",
2175 ];
2176 let (mut decoder, _text, _events) = drive(&lines);
2177 assert_eq!(decoder.completed_tools.len(), 1);
2178
2179 decoder.finish();
2181 assert_eq!(
2182 decoder.completed_tools.len(),
2183 1,
2184 "finish() called twice must not double-emit"
2185 );
2186 }
2187
2188 #[test]
2189 fn finish_drains_active_tools_for_state_hygiene() {
2190 let lines = [
2195 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_z","name":"bash"}}"#,
2196 "",
2197 "data: [DONE]",
2198 "",
2199 ];
2200 let (decoder, _text, _events) = drive(&lines);
2201 assert_eq!(decoder.completed_tools.len(), 1);
2202 assert!(
2203 decoder.active_tools.is_empty(),
2204 "active_tools must be drained after finish()"
2205 );
2206 }
2207
2208 #[test]
2209 fn unknown_event_types_are_ignored() {
2210 let lines = [
2211 r#"data: {"type":"response.future_unknown_event","payload":{"x":1}}"#,
2212 "",
2213 r#"data: {"type":"response.output_text.delta","delta":"hi"}"#,
2214 "",
2215 ];
2216 let (_decoder, text_acc, _events) = drive(&lines);
2217 assert_eq!(text_acc, "hi");
2218 }
2219}
2220
2221#[cfg(test)]
2222mod broker_stream_tests {
2223 use super::*;
2229 use crate::auth::{CredentialBroker, LocalBroker};
2230 use std::sync::Arc;
2231
2232 async fn spawn_fake_openai_sse() -> (String, Arc<std::sync::Mutex<String>>) {
2233 use axum::routing::post;
2234 let seen_auth = Arc::new(std::sync::Mutex::new(String::new()));
2235 let seen = seen_auth.clone();
2236 let app = axum::Router::new().route(
2237 "/chat/completions",
2238 post(move |headers: axum::http::HeaderMap| {
2239 let seen = seen.clone();
2240 async move {
2241 *seen.lock().unwrap() = headers
2242 .get("authorization")
2243 .and_then(|v| v.to_str().ok())
2244 .unwrap_or("")
2245 .to_string();
2246 let body = concat!(
2247 "data: {\"choices\":[{\"delta\":{\"content\":\"Hel\"}}]}\n\n",
2248 "data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}\n\n",
2249 "data: [DONE]\n\n",
2250 );
2251 ([("content-type", "text/event-stream")], body)
2252 }
2253 }),
2254 );
2255 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2256 let addr = listener.local_addr().unwrap();
2257 tokio::spawn(async move {
2258 axum::serve(listener, app).await.unwrap();
2259 });
2260 (format!("http://{addr}"), seen_auth)
2261 }
2262
2263 #[tokio::test]
2264 async fn oai_stream_flows_through_broker_and_forwards_deltas() {
2265 let (upstream, seen_auth) = spawn_fake_openai_sse().await;
2266 let broker: Arc<dyn CredentialBroker> = Arc::new(LocalBroker::with_local_base_url(
2267 reqwest::Client::new(),
2268 upstream,
2269 ));
2270 let cfg = ProviderConfig {
2271 base_url: "unused-broker-derives-the-url".to_string(),
2272 model: "test-model".to_string(),
2273 provider: "local".to_string(),
2274 };
2275 let (tx, mut rx) = mpsc::unbounded_channel::<StreamEvent>();
2276 let cancel = tokio_util::sync::CancellationToken::new();
2277
2278 let result = call_oai_stream_inner(
2279 &cfg,
2280 &broker,
2281 &[],
2282 &None,
2283 &[],
2284 &tx,
2285 None,
2286 None,
2287 0,
2288 agent_core::reasoning::ReasoningLevel::Medium,
2289 &cancel,
2290 &crate::runtime::trace::TraceContext::disabled(),
2291 true,
2292 false,
2293 )
2294 .await
2295 .expect("stream must complete");
2296
2297 assert_eq!(result["content"][0]["text"], "Hello");
2299
2300 drop(tx);
2302 let mut streamed = String::new();
2303 while let Ok(ev) = rx.try_recv() {
2304 if let StreamEvent::Llm(crate::runtime::types::LlmEvent::Text(t)) = ev {
2305 streamed.push_str(&t);
2306 }
2307 }
2308 assert_eq!(streamed, "Hello");
2309
2310 assert_eq!(&*seen_auth.lock().unwrap(), "Bearer local");
2312 }
2313
2314 #[tokio::test]
2317 async fn suppressed_sync_route_does_not_enqueue_display_deltas() {
2318 let (upstream, _seen_auth) = spawn_fake_openai_sse().await;
2319 let broker: Arc<dyn CredentialBroker> = Arc::new(LocalBroker::with_local_base_url(
2320 reqwest::Client::new(),
2321 upstream,
2322 ));
2323 let cfg = ProviderConfig {
2324 base_url: "unused-broker-derives-the-url".to_string(),
2325 model: "test-model".to_string(),
2326 provider: "local".to_string(),
2327 };
2328 let (tx, mut rx) = mpsc::unbounded_channel::<StreamEvent>();
2329 let result = call_oai_stream_inner(
2330 &cfg,
2331 &broker,
2332 &[],
2333 &None,
2334 &[],
2335 &tx,
2336 None,
2337 None,
2338 0,
2339 agent_core::reasoning::ReasoningLevel::Medium,
2340 &tokio_util::sync::CancellationToken::new(),
2341 &crate::runtime::trace::TraceContext::disabled(),
2342 true,
2343 true,
2344 )
2345 .await
2346 .expect("stream must complete");
2347 assert_eq!(result["content"][0]["text"], "Hello");
2348 assert!(
2349 rx.try_recv().is_err(),
2350 "sync route must suppress display deltas at production"
2351 );
2352 }
2353
2354 #[tokio::test]
2355 async fn oai_stream_broker_error_fails_closed() {
2356 let broker: Arc<dyn CredentialBroker> = Arc::new(LocalBroker::new(reqwest::Client::new()));
2358 let cfg = ProviderConfig {
2359 base_url: String::new(),
2360 model: "m".to_string(),
2361 provider: "definitely-not-a-provider".to_string(),
2362 };
2363 let (tx, _rx) = mpsc::unbounded_channel::<StreamEvent>();
2364 let cancel = tokio_util::sync::CancellationToken::new();
2365 let err = call_oai_stream_inner(
2366 &cfg,
2367 &broker,
2368 &[],
2369 &None,
2370 &[],
2371 &tx,
2372 None,
2373 None,
2374 0,
2375 agent_core::reasoning::ReasoningLevel::Medium,
2376 &cancel,
2377 &crate::runtime::trace::TraceContext::disabled(),
2378 true,
2379 false,
2380 )
2381 .await
2382 .unwrap_err()
2383 .to_string();
2384 assert!(err.contains("unknown provider"), "got: {err}");
2385 assert!(!err.to_lowercase().contains("bearer"));
2386 }
2387
2388 #[tokio::test]
2393 async fn oai_stream_upstream_error_body_never_surfaces() {
2394 use axum::response::IntoResponse;
2395 const SENTINEL: &str = "PH1-OAI-BROKER-SENTINEL-91d2-RAW";
2396 let app = axum::Router::new().route(
2397 "/chat/completions",
2398 axum::routing::post(move |body: String| async move {
2399 (
2400 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
2401 [("content-type", "application/json")],
2402 format!("{{\"error\":{{\"message\":\"ECHOED:{body}\"}}}}"),
2403 )
2404 .into_response()
2405 }),
2406 );
2407 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2408 let addr = listener.local_addr().unwrap();
2409 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
2410
2411 let broker: Arc<dyn CredentialBroker> = Arc::new(LocalBroker::with_local_base_url(
2412 reqwest::Client::new(),
2413 format!("http://{addr}"),
2414 ));
2415 let cfg = ProviderConfig {
2416 base_url: "unused-broker-derives-the-url".to_string(),
2417 model: "test-model".to_string(),
2418 provider: "local".to_string(),
2419 };
2420 let tools = vec![serde_json::json!({
2421 "name": "ph1_secret_tool_zz",
2422 "description": "internal tool schema",
2423 "input_schema": {"type": "object", "properties": {}}
2424 })];
2425 let msgs: Vec<crate::SharedMessage> = vec![Arc::new(
2426 serde_json::json!({"role": "user", "content": SENTINEL}),
2427 )];
2428 let (tx, _rx) = mpsc::unbounded_channel::<StreamEvent>();
2429 let cancel = tokio_util::sync::CancellationToken::new();
2430
2431 let err = call_oai_stream_inner(
2432 &cfg,
2433 &broker,
2434 &tools,
2435 &Some(format!("system secret {SENTINEL}")),
2436 &msgs,
2437 &tx,
2438 None,
2439 None,
2440 0,
2441 agent_core::reasoning::ReasoningLevel::Medium,
2442 &cancel,
2443 &crate::runtime::trace::TraceContext::disabled(),
2444 true,
2445 false,
2446 )
2447 .await
2448 .expect_err("500 must fail")
2449 .to_string();
2450
2451 assert!(err.contains("500"), "status must survive: {err}");
2452 for banned in ["ECHOED", SENTINEL, "ph1_secret_tool_zz", "system secret"] {
2453 assert!(
2454 !err.contains(banned),
2455 "provider body content `{banned}` leaked into the surfaced error: {err}"
2456 );
2457 }
2458 }
2459}
2460
2461const XAI_PROVIDER_LABEL: &str = "xAI";
2463
2464#[allow(clippy::too_many_arguments)]
2465pub(crate) async fn call_xai_responses_stream_inner(
2466 cfg: &ProviderConfig,
2467 broker: &std::sync::Arc<dyn crate::auth::CredentialBroker>,
2468 tools_schema: &[Value],
2469 system_prompt: &Option<String>,
2470 messages: &[crate::SharedMessage],
2471 tx: &mpsc::UnboundedSender<StreamEvent>,
2472 max_tokens: Option<u32>,
2473 reasoning_level: agent_core::reasoning::ReasoningLevel,
2474 cancel: &tokio_util::sync::CancellationToken,
2475 max_retries: u32,
2476 trace: &crate::runtime::trace::TraceContext,
2477 exact_wire_bytes: bool,
2478) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
2479 let (oai_tools, names) = translate::tools_to_oai(tools_schema);
2480 let input = codex_input_messages(translate::messages_to_oai(messages, system_prompt, &names));
2481 let tools: Vec<Value> = oai_tools
2482 .into_iter()
2483 .map(|tool| {
2484 json!({
2485 "type": "function",
2486 "name": tool.function.name,
2487 "description": tool.function.description.unwrap_or_default(),
2488 "parameters": tool.function.parameters,
2489 })
2490 })
2491 .collect();
2492 let body = build_xai_body(
2495 &cfg.model,
2496 reasoning_level,
2497 serde_json::to_value(input)?,
2498 tools,
2499 max_tokens,
2500 )
2501 .map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
2502 let (proxy_request, body_bytes) =
2510 crate::auth::ProxyRequest::post_json_exact("xai-auth", "/responses", body, true)?;
2511 let tracer = tr::begin_openai_tracer(
2512 trace,
2513 &cfg.provider,
2514 &cfg.model,
2515 if exact_wire_bytes {
2516 crate::runtime::trace::TransportKind::OpenAiResponses
2517 } else {
2518 crate::runtime::trace::TransportKind::CloudProxy
2519 },
2520 &format!("{}/responses", cfg.base_url.trim_end_matches('/')),
2521 exact_wire_bytes.then_some(body_bytes.as_ref()),
2522 messages,
2523 system_prompt.as_deref(),
2524 tools_schema,
2525 tr::renamed_tool_losses(&names),
2526 )
2527 .await;
2528 if let Some(tracer) = &tracer {
2534 trace.capture_request_content(tracer.request_id(), body_bytes.as_ref());
2535 }
2536 let mut attempt = tr::StreamAttempt::new(tracer);
2537 let mut stream_retry = 0u32;
2538
2539 loop {
2540 let stream = broker
2541 .proxy_stream(proxy_request.clone())
2542 .await
2543 .map_err(|e| super::net::redact_provider_proxy_error(&e.to_string()));
2546 let mut stream = match stream {
2547 Ok(stream) => {
2548 attempt.mark_headers();
2549 stream
2550 }
2551 Err(msg) => {
2552 let status = tr::broker_error_status(&msg);
2553 let code =
2554 status.map_or_else(|| "broker_error".to_string(), |s| format!("http_{s}"));
2555 attempt.finish_failed(&code, status, None);
2556 return Err(msg.into());
2557 }
2558 };
2559 let mut text = String::new();
2560 let mut parser = CodexSseDecoder::for_provider(XAI_PROVIDER_LABEL);
2561 let mut buf = bytes::BytesMut::new();
2562 while let Some(chunk) = tokio::select! {
2563 c = stream.next() => c,
2564 _ = cancel.cancelled() => {
2565 attempt.finish_canceled(None, parser.trace_usage());
2566 return Err("request canceled".into());
2567 }
2568 } {
2569 let chunk = match chunk {
2570 Ok(chunk) => {
2571 attempt.mark_first_byte();
2572 chunk
2573 }
2574 Err(e) => {
2575 attempt.finish_failed("stream_error", None, None);
2576 return Err(e.into());
2577 }
2578 };
2579 buf.extend_from_slice(&chunk);
2580 while let Some(n) = memchr::memchr(b'\n', &buf) {
2581 let line = buf.split_to(n + 1);
2582 parser.push_line(std::str::from_utf8(&line[..n]).unwrap_or(""), tx, &mut text);
2583 }
2584 if parser.saw_model_event {
2585 attempt.mark_first_model_event();
2586 }
2587 }
2588 if !buf.is_empty() {
2589 parser.push_line(std::str::from_utf8(&buf).unwrap_or(""), tx, &mut text);
2590 }
2591 parser.finish();
2592 if parser.saw_model_event {
2596 attempt.mark_first_model_event();
2597 }
2598 if let Err(failure) = parser.terminal_result() {
2602 let output_already_streamed = !text.is_empty() || parser.emitted_any_output();
2608 if failure.is_retryable() && !output_already_streamed && stream_retry < max_retries {
2609 stream_retry += 1;
2610 let delay = retry_delay(stream_retry);
2611 attempt.attempt_failed(
2612 crate::runtime::trace::RetryClass::Other,
2613 delay,
2614 None,
2615 None,
2616 failure.code,
2617 );
2618 tracing::warn!(
2619 code = failure.code,
2620 "xai stream retry {stream_retry}/{max_retries} after {delay:?}"
2621 );
2622 tokio::select! {
2623 _ = tokio::time::sleep(delay) => {}
2624 _ = cancel.cancelled() => {
2625 attempt.finish_canceled(None, None);
2626 return Err("request canceled".into());
2627 }
2628 }
2629 attempt.restart_clock();
2630 continue;
2631 }
2632 attempt.finish_failed(failure.code, None, None);
2633 return Err(failure.message.into());
2634 }
2635 attempt.finish_success(None, None, None, parser.trace_usage());
2636 let mut content = Vec::new();
2637 if !text.is_empty() {
2638 content.push(json!({"type":"text","text":text}));
2639 }
2640 content.extend(translate::tool_calls_to_content_blocks(
2641 &parser.completed_tools,
2642 &names,
2643 ));
2644 return Ok(json!({"role":"assistant","content":content}));
2645 }
2646}
2647
2648pub(crate) fn build_xai_body(
2659 model: &str,
2660 level: agent_core::reasoning::ReasoningLevel,
2661 input: Value,
2662 tools: Vec<Value>,
2663 max_tokens: Option<u32>,
2664) -> Result<Value, String> {
2665 use agent_core::reasoning::ReasoningLevel;
2666 crate::runtime::openai::catalog::validation::validate_reasoning_mutation(
2667 &format!("xai-auth/{model}"),
2668 level,
2669 )?;
2670 let mut body = serde_json::Map::new();
2671 body.insert("model".into(), json!(model));
2672 body.insert("input".into(), input);
2673 body.insert("stream".into(), json!(true));
2674 if !tools.is_empty() {
2675 body.insert("tools".into(), Value::Array(tools));
2676 }
2677 if let Some(max) = max_tokens {
2678 body.insert("max_output_tokens".into(), json!(max));
2679 }
2680 match level {
2681 ReasoningLevel::Off | ReasoningLevel::Adaptive => {}
2684 l => {
2685 body.insert("reasoning".into(), json!({ "effort": l.as_str() }));
2686 }
2687 }
2688 Ok(Value::Object(body))
2689}
2690
2691#[cfg(test)]
2692mod xai_tests {
2693 use super::*;
2694 use agent_core::reasoning::ReasoningLevel;
2695
2696 #[test]
2697 fn xai_fixture_is_public_responses_shape() {
2698 let fixture = serde_json::json!({"type":"response.output_text.delta","delta":"hello"});
2699 assert_eq!(fixture["type"], "response.output_text.delta");
2700 assert_eq!(fixture["delta"], "hello");
2701 }
2702
2703 fn body_for(model: &str, level: ReasoningLevel) -> Result<Value, String> {
2704 build_xai_body(model, level, json!([]), Vec::new(), Some(1024))
2705 }
2706
2707 #[test]
2710 fn grok45_emits_exact_documented_efforts() {
2711 for (level, effort) in [
2712 (ReasoningLevel::Low, "low"),
2713 (ReasoningLevel::Medium, "medium"),
2714 (ReasoningLevel::High, "high"),
2715 ] {
2716 let body = body_for("grok-4.5", level).expect("supported effort");
2717 assert_eq!(body["reasoning"], json!({"effort": effort}), "{level}");
2718 assert_eq!(body["model"], "grok-4.5");
2719 assert_eq!(body["stream"], json!(true));
2720 assert_eq!(body["max_output_tokens"], json!(1024));
2721 }
2722 }
2723
2724 #[test]
2725 fn grok46_emits_exact_documented_efforts() {
2726 for (level, effort) in [
2727 (ReasoningLevel::Low, "low"),
2728 (ReasoningLevel::Medium, "medium"),
2729 (ReasoningLevel::High, "high"),
2730 ] {
2731 let body = body_for("grok-4.6", level).expect("supported effort");
2732 assert_eq!(body["reasoning"], json!({"effort": effort}), "{level}");
2733 assert_eq!(body["model"], "grok-4.6");
2734 assert_eq!(body["stream"], json!(true));
2735 assert_eq!(body["max_output_tokens"], json!(1024));
2736 }
2737 }
2738
2739 #[test]
2740 fn adaptive_omits_reasoning_field_provider_default() {
2741 for model in [
2742 "grok-4.5",
2743 "grok-4.5-latest",
2744 "grok-4.6",
2745 "grok-4.3",
2746 "grok-4.20-0309-non-reasoning",
2747 ] {
2748 let body = body_for(model, ReasoningLevel::Adaptive).expect("adaptive is omission");
2749 assert!(body.get("reasoning").is_none(), "{model}");
2750 }
2751 }
2752
2753 #[test]
2754 fn off_is_rejected_pre_network_when_reasoning_cannot_be_disabled() {
2755 for model in [
2756 "grok-4.5",
2757 "grok-4.5-latest",
2758 "grok-4.6",
2759 "grok-4.20-multi-agent-0309",
2760 "grok-4.3",
2761 ] {
2762 let err = body_for(model, ReasoningLevel::Off).unwrap_err();
2763 assert!(err.contains(model), "{model}: {err}");
2764 }
2765 let body = body_for("grok-4.20-0309-non-reasoning", ReasoningLevel::Off).unwrap();
2767 assert!(body.get("reasoning").is_none());
2768 }
2769
2770 #[test]
2771 fn unsupported_named_efforts_are_rejected_pre_network() {
2772 for level in [
2774 ReasoningLevel::XHigh,
2775 ReasoningLevel::Max,
2776 ReasoningLevel::Ultra,
2777 ] {
2778 assert!(body_for("grok-4.5", level).is_err(), "{level}");
2779 assert!(body_for("grok-4.6", level).is_err(), "{level}");
2780 }
2781 assert!(body_for("grok-4.3", ReasoningLevel::Medium).is_err());
2783 assert!(body_for("grok-4.20-0309-non-reasoning", ReasoningLevel::Medium).is_err());
2785 assert!(body_for("grok-9000", ReasoningLevel::Medium).is_err());
2787 }
2788
2789 #[test]
2790 fn multi_agent_xhigh_is_exact_agent_count_control() {
2791 let body = body_for("grok-4.20-multi-agent-0309", ReasoningLevel::XHigh).unwrap();
2792 assert_eq!(body["reasoning"], json!({"effort": "xhigh"}));
2793 }
2794
2795 #[test]
2796 fn responses_wire_shape_tools_flat_and_no_chat_completions_fields() {
2797 let tools = vec![json!({
2798 "type": "function",
2799 "name": "get_weather",
2800 "description": "d",
2801 "parameters": {"type": "object"}
2802 })];
2803 let body =
2804 build_xai_body("grok-4.5", ReasoningLevel::High, json!([]), tools, None).unwrap();
2805 assert_eq!(body["tools"][0]["name"], "get_weather");
2808 assert!(body["tools"][0].get("function").is_none());
2809 assert!(body.get("input").is_some());
2810 assert!(body.get("messages").is_none());
2811 assert!(body.get("max_output_tokens").is_none());
2812 }
2813}
2814
2815#[cfg(test)]
2816mod xai_capacity_retry_tests {
2817 use super::*;
2826 use agent_core::auth::{
2827 AccessToken, BrokerError, ProxyByteStream, ProxyRequest, ProxyResponse,
2828 };
2829 use async_trait::async_trait;
2830 use std::sync::{
2831 atomic::{AtomicUsize, Ordering},
2832 Arc,
2833 };
2834
2835 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";
2838
2839 const XAI_SUCCESS_SSE: &str = concat!(
2840 "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\"}}\n\n",
2841 "data: {\"type\":\"response.output_text.delta\",\"delta\":\"GROK_OK\"}\n\n",
2842 "data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":7,\"output_tokens\":2}}}\n\n",
2843 );
2844
2845 struct ScriptedXaiBroker {
2848 bodies: Vec<&'static str>,
2849 calls: Arc<AtomicUsize>,
2850 seen: Arc<std::sync::Mutex<Vec<ProxyRequest>>>,
2851 }
2852
2853 #[async_trait]
2854 impl crate::auth::CredentialBroker for ScriptedXaiBroker {
2855 async fn access_token(
2856 &self,
2857 _p: agent_core::auth::OAuthProviderId,
2858 ) -> Result<AccessToken, BrokerError> {
2859 Err(BrokerError::Denied(
2860 "xai route must use proxy_stream".into(),
2861 ))
2862 }
2863 async fn proxy(&self, _request: ProxyRequest) -> Result<ProxyResponse, BrokerError> {
2864 Err(BrokerError::Denied("not implemented in stub".into()))
2865 }
2866 async fn proxy_stream(
2867 &self,
2868 request: ProxyRequest,
2869 ) -> Result<ProxyByteStream, BrokerError> {
2870 request.validate()?;
2871 let n = self.calls.fetch_add(1, Ordering::SeqCst);
2872 self.seen.lock().unwrap().push(request);
2873 let body = self.bodies[n.min(self.bodies.len() - 1)];
2874 Ok(Box::pin(futures::stream::iter(vec![Ok(
2875 bytes::Bytes::from(body),
2876 )])))
2877 }
2878 async fn anthropic_usage(&self) -> Result<serde_json::Value, BrokerError> {
2879 Err(BrokerError::Denied("not implemented in stub".into()))
2880 }
2881 async fn capabilities(&self) -> Result<Vec<agent_core::auth::ProviderStatus>, BrokerError> {
2882 Ok(vec![])
2883 }
2884 }
2885
2886 struct Run {
2887 result: Result<Value, Box<dyn std::error::Error + Send + Sync>>,
2888 calls: usize,
2889 seen: Vec<ProxyRequest>,
2890 events: Vec<StreamEvent>,
2891 }
2892
2893 async fn run_xai(bodies: Vec<&'static str>, max_retries: u32) -> Run {
2894 let calls = Arc::new(AtomicUsize::new(0));
2895 let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
2896 let broker: Arc<dyn crate::auth::CredentialBroker> = Arc::new(ScriptedXaiBroker {
2897 bodies,
2898 calls: Arc::clone(&calls),
2899 seen: Arc::clone(&seen),
2900 });
2901 let cfg = ProviderConfig {
2902 base_url: "https://api.x.ai/v1".to_string(),
2903 model: "grok-4.5".to_string(),
2904 provider: "xai-auth".to_string(),
2905 };
2906 let (tx, mut rx) = mpsc::unbounded_channel::<StreamEvent>();
2907 let result = call_xai_responses_stream_inner(
2908 &cfg,
2909 &broker,
2910 &[],
2911 &Some("system".to_string()),
2912 &[],
2913 &tx,
2914 None,
2915 agent_core::reasoning::ReasoningLevel::Adaptive,
2916 &tokio_util::sync::CancellationToken::new(),
2917 max_retries,
2918 &crate::runtime::trace::TraceContext::disabled(),
2919 true,
2920 )
2921 .await;
2922 drop(tx);
2923 let mut events = Vec::new();
2924 while let Ok(event) = rx.try_recv() {
2925 events.push(event);
2926 }
2927 let seen = std::mem::take(&mut *seen.lock().unwrap());
2928 Run {
2929 result,
2930 calls: calls.load(Ordering::SeqCst),
2931 seen,
2932 events,
2933 }
2934 }
2935
2936 fn text_events(events: &[StreamEvent]) -> Vec<String> {
2937 events
2938 .iter()
2939 .filter_map(|e| match e {
2940 StreamEvent::Llm(crate::runtime::types::LlmEvent::Text(t)) => Some(t.clone()),
2941 _ => None,
2942 })
2943 .collect()
2944 }
2945
2946 #[tokio::test(start_paused = true)]
2947 async fn xai_capacity_error_is_retried_with_identical_bytes_then_succeeds() {
2948 let run = run_xai(vec![XAI_CAPACITY_SSE, XAI_SUCCESS_SSE], 3).await;
2949 let value = run.result.expect("second attempt must succeed");
2950 assert_eq!(value["content"][0]["text"], "GROK_OK");
2951 assert_eq!(run.calls, 2, "exactly one retry");
2952 assert_eq!(run.seen.len(), 2);
2953 assert_eq!(
2954 run.seen[0].body_bytes, run.seen[1].body_bytes,
2955 "retry must re-send the identical serialized body"
2956 );
2957 assert_eq!(run.seen[0].body, run.seen[1].body);
2958 assert_eq!(run.seen[0].provider, "xai-auth");
2959 assert_eq!(run.seen[0].path, "/responses");
2960 assert_eq!(text_events(&run.events), vec!["GROK_OK".to_string()]);
2962 }
2963
2964 #[tokio::test(start_paused = true)]
2965 async fn xai_capacity_error_with_zero_retries_surfaces_xai_capacity_message() {
2966 let run = run_xai(vec![XAI_CAPACITY_SSE], 0).await;
2967 let err = run.result.expect_err("no retry budget → terminal");
2968 let msg = err.to_string();
2969 assert_eq!(
2970 msg,
2971 "xAI reports the model is at capacity (retries exhausted). Try again in a few minutes or switch models with /model."
2972 );
2973 assert_eq!(run.calls, 1);
2974 for banned in ["Codex", "high demand", "docs.x.ai", "service tier"] {
2975 assert!(!msg.contains(banned), "`{banned}` leaked: {msg}");
2976 }
2977 assert!(matches!(
2979 super::super::net::provider_error_to_runtime(err),
2980 crate::RuntimeError::ApiStatus(_)
2981 ));
2982 }
2983
2984 #[tokio::test(start_paused = true)]
2985 async fn xai_capacity_error_exhausting_budget_counts_every_attempt() {
2986 let run = run_xai(vec![XAI_CAPACITY_SSE], 2).await;
2987 let err = run.result.expect_err("persistent capacity → terminal");
2988 assert!(err
2989 .to_string()
2990 .starts_with("xAI reports the model is at capacity"));
2991 assert_eq!(run.calls, 3, "initial attempt + 2 retries");
2992 assert!(text_events(&run.events).is_empty());
2993 }
2994
2995 #[tokio::test(start_paused = true)]
2998 async fn xai_does_not_retry_after_partial_text_was_streamed() {
2999 const PARTIAL_THEN_ERROR: &str = concat!(
3000 "data: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\n\n",
3001 "data: {\"type\":\"error\",\"code\":null,\"message\":\"at capacity\"}\n\n",
3002 );
3003 let run = run_xai(vec![PARTIAL_THEN_ERROR, XAI_SUCCESS_SSE], 3).await;
3004 let err = run.result.expect_err("must not retry after partial output");
3005 assert!(err
3006 .to_string()
3007 .starts_with("xAI reports the model is at capacity"));
3008 assert_eq!(run.calls, 1, "no retry once output reached the UI");
3009 assert_eq!(text_events(&run.events), vec!["partial".to_string()]);
3010 }
3011
3012 #[tokio::test(start_paused = true)]
3014 async fn xai_does_not_retry_after_tool_events_were_emitted() {
3015 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";
3016 let run = run_xai(vec![TOOL_THEN_EOF, XAI_SUCCESS_SSE], 3).await;
3017 let err = run
3021 .result
3022 .expect_err("missing terminal after a tool event stays terminal");
3023 assert!(
3024 err.to_string()
3025 .starts_with("xAI response stream ended without a terminal event."),
3026 "got: {err}"
3027 );
3028 assert_eq!(run.calls, 1, "no retry once a tool event reached the UI");
3029 assert!(run.events.iter().any(|e| matches!(
3030 e,
3031 StreamEvent::Llm(crate::runtime::types::LlmEvent::ToolUseStart { .. })
3032 )));
3033 }
3034
3035 #[tokio::test(start_paused = true)]
3038 async fn xai_non_capacity_error_is_terminal_and_labelled_xai() {
3039 const OTHER_ERROR: &str = "data: {\"type\":\"error\",\"code\":\"invalid_api_key\",\"message\":\"ECHOED:secret prompt\"}\n\n";
3040 let run = run_xai(vec![OTHER_ERROR, XAI_SUCCESS_SSE], 3).await;
3041 let err = run.result.expect_err("must fail");
3042 let msg = err.to_string();
3043 assert_eq!(
3044 msg,
3045 "xAI response failed in stream. Provider error details withheld because they can echo request content."
3046 );
3047 assert!(!msg.contains("ECHOED"));
3048 assert_eq!(run.calls, 1);
3049 }
3050}
3051
3052#[cfg(test)]
3055mod codex_wire_tests {
3056 use crate::runtime::openai::catalog::{
3057 plan_codex_execution, CodexMultiAgentMode, CodexRequestRole,
3058 };
3059 use agent_core::reasoning::ReasoningLevel;
3060
3061 fn plan(
3062 level: ReasoningLevel,
3063 role: CodexRequestRole,
3064 ) -> crate::runtime::openai::catalog::CodexExecutionPlan {
3065 plan_codex_execution("openai-codex/gpt-5.6-sol", level, role, None)
3066 .expect("Sol request plan")
3067 }
3068
3069 #[allow(clippy::too_many_arguments)]
3070 fn body_for_level(
3071 model: &str,
3072 level: ReasoningLevel,
3073 input: serde_json::Value,
3074 instructions: String,
3075 tools: Vec<serde_json::Value>,
3076 temperature: Option<f32>,
3077 max_tokens: Option<u32>,
3078 ) -> serde_json::Value {
3079 let qualified = format!("openai-codex/{model}");
3080 let plan = plan_codex_execution(&qualified, level, CodexRequestRole::Foreground, None)
3081 .expect("request plan");
3082 super::build_codex_body(
3083 model,
3084 &plan,
3085 input,
3086 instructions,
3087 tools,
3088 temperature,
3089 max_tokens,
3090 "synaps-test-cache-key",
3091 )
3092 }
3093
3094 fn codex_effort_for(model_id: &str, level: ReasoningLevel) -> Result<String, String> {
3096 let qualified = format!("openai-codex/{model_id}");
3097 let plan = plan_codex_execution(&qualified, level, CodexRequestRole::Foreground, None)
3098 .map_err(|error| error.to_string())?;
3099 plan.wire_effort
3100 .map(|effort| effort.as_str().to_string())
3101 .ok_or_else(|| "reasoning effort omitted".to_string())
3102 }
3103
3104 #[test]
3105 fn sol_lowers_ultra_to_max_for_requests() {
3106 let effort = codex_effort_for("gpt-5.6-sol", ReasoningLevel::Ultra).unwrap();
3107 assert_eq!(effort, "max");
3108 }
3109
3110 #[test]
3111 fn sol_emits_max_exact() {
3112 let effort = codex_effort_for("gpt-5.6-sol", ReasoningLevel::Max).unwrap();
3113 assert_eq!(effort, "max");
3114 }
3115
3116 #[test]
3117 fn sol_emits_xhigh_exact() {
3118 let effort = codex_effort_for("gpt-5.6-sol", ReasoningLevel::XHigh).unwrap();
3119 assert_eq!(effort, "xhigh");
3120 }
3121
3122 #[test]
3123 fn luna_emits_max_exact() {
3124 let effort = codex_effort_for("gpt-5.6-luna", ReasoningLevel::Max).unwrap();
3125 assert_eq!(effort, "max");
3126 }
3127
3128 #[test]
3129 fn luna_rejects_ultra_before_network() {
3130 let err = codex_effort_for("gpt-5.6-luna", ReasoningLevel::Ultra).unwrap_err();
3132 assert!(err.contains("ultra"), "{err}");
3133 assert!(err.contains("gpt-5.6-luna"), "{err}");
3134 }
3135
3136 #[test]
3137 fn gpt55_emits_xhigh_exact() {
3138 let effort = codex_effort_for("gpt-5.5", ReasoningLevel::XHigh).unwrap();
3139 assert_eq!(effort, "xhigh");
3140 }
3141
3142 #[test]
3143 fn gpt55_rejects_max_before_network() {
3144 assert!(codex_effort_for("gpt-5.5", ReasoningLevel::Max).is_err());
3145 assert!(codex_effort_for("gpt-5.5", ReasoningLevel::Ultra).is_err());
3146 }
3147
3148 #[test]
3149 fn off_and_adaptive_omit_reasoning_field() {
3150 for level in [ReasoningLevel::Off, ReasoningLevel::Adaptive] {
3152 let body = body_for_level(
3153 "gpt-5.6-sol",
3154 level,
3155 serde_json::json!([]),
3156 "sys".to_string(),
3157 vec![],
3158 None,
3159 None,
3160 );
3161 assert!(
3162 body.get("reasoning").is_none(),
3163 "{level:?} must not emit reasoning field, body={body}"
3164 );
3165 }
3166 }
3167
3168 #[test]
3169 fn max_as_str_is_not_xhigh() {
3170 assert_ne!(ReasoningLevel::Max.as_str(), ReasoningLevel::XHigh.as_str());
3171 assert_eq!(ReasoningLevel::Max.as_str(), "max");
3172 }
3173
3174 #[test]
3175 fn ultra_mode_context_is_exact_proactive_developer_item() {
3176 let plan = plan(ReasoningLevel::Ultra, CodexRequestRole::Foreground);
3177 assert_eq!(plan.multi_agent_mode, Some(CodexMultiAgentMode::Proactive));
3178 let item = super::codex_multi_agent_mode_item(&plan).expect("Ultra context item");
3179 assert_eq!(
3180 item.get("type").and_then(serde_json::Value::as_str),
3181 Some("message")
3182 );
3183 assert_eq!(
3184 item.get("role").and_then(serde_json::Value::as_str),
3185 Some("developer")
3186 );
3187 assert_eq!(
3188 item.pointer("/content/0/text")
3189 .and_then(serde_json::Value::as_str),
3190 Some(concat!(
3191 "<multi_agent_mode>",
3192 "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.",
3193 "</multi_agent_mode>"
3194 ))
3195 );
3196 }
3197
3198 #[test]
3199 fn max_and_xhigh_mode_context_is_exact_explicit_only_item() {
3200 for level in [ReasoningLevel::Max, ReasoningLevel::XHigh] {
3201 let plan = plan(level, CodexRequestRole::Foreground);
3202 assert_eq!(
3203 plan.multi_agent_mode,
3204 Some(CodexMultiAgentMode::ExplicitRequestOnly)
3205 );
3206 let item = super::codex_multi_agent_mode_item(&plan).expect("v2 context item");
3207 assert_eq!(
3208 item.pointer("/content/0/text")
3209 .and_then(serde_json::Value::as_str),
3210 Some(concat!(
3211 "<multi_agent_mode>",
3212 "Do not spawn sub-agents unless the user or applicable AGENTS.md/skill instructions explicitly ask for sub-agents, delegation, or parallel agent work.",
3213 "</multi_agent_mode>"
3214 )),
3215 "{level:?}"
3216 );
3217 }
3218 }
3219
3220 #[test]
3221 fn mode_context_is_inserted_once_at_stable_head_position() {
3222 let plan = plan(ReasoningLevel::Ultra, CodexRequestRole::Foreground);
3223 let mut input = vec![
3224 serde_json::json!({"role":"user","content":"first"}),
3225 serde_json::json!({"role":"assistant","content":"answer"}),
3226 serde_json::json!({"role":"user","content":"current"}),
3227 ];
3228 super::insert_codex_multi_agent_mode(&mut input, &plan);
3229 let mode_positions = input
3230 .iter()
3231 .enumerate()
3232 .filter(|(_, item)| {
3233 item.pointer("/content/0/text")
3234 .and_then(serde_json::Value::as_str)
3235 .is_some_and(|text| text.starts_with("<multi_agent_mode>"))
3236 })
3237 .map(|(index, _)| index)
3238 .collect::<Vec<_>>();
3239 assert_eq!(mode_positions, vec![0]);
3240 assert_eq!(
3241 input[3].get("content").and_then(serde_json::Value::as_str),
3242 Some("current")
3243 );
3244 }
3245
3246 #[test]
3252 fn mode_context_placement_preserves_prompt_prefix_across_turns() {
3253 let plan = plan(ReasoningLevel::Ultra, CodexRequestRole::Foreground);
3254 let turn1 = vec![
3255 serde_json::json!({"role":"user","content":"first"}),
3256 serde_json::json!({"role":"assistant","content":"answer"}),
3257 ];
3258 let mut turn2 = turn1.clone();
3259 turn2.push(serde_json::json!({"role":"user","content":"follow-up"}));
3260 let mut input1 = turn1;
3261 let mut input2 = turn2;
3262 super::insert_codex_multi_agent_mode(&mut input1, &plan);
3263 super::insert_codex_multi_agent_mode(&mut input2, &plan);
3264 assert_eq!(
3265 &input2[..input1.len()],
3266 &input1[..],
3267 "turn N's input must be a strict prefix of turn N+1's input"
3268 );
3269 }
3270
3271 #[test]
3272 fn prompt_cache_key_is_stable_for_the_same_conversation_head() {
3273 let first = serde_json::json!({"role":"user","content":"hello"});
3274 let a = super::codex_prompt_cache_key("sys", Some(&first));
3275 let b = super::codex_prompt_cache_key("sys", Some(&first));
3276 assert_eq!(a, b);
3277 assert!(a.starts_with("synaps-"), "{a}");
3278 }
3279
3280 #[test]
3281 fn prompt_cache_key_differs_across_conversations() {
3282 let first = serde_json::json!({"role":"user","content":"hello"});
3283 let other = serde_json::json!({"role":"user","content":"different task"});
3284 assert_ne!(
3285 super::codex_prompt_cache_key("sys", Some(&first)),
3286 super::codex_prompt_cache_key("sys", Some(&other)),
3287 );
3288 assert_ne!(
3289 super::codex_prompt_cache_key("sys", Some(&first)),
3290 super::codex_prompt_cache_key("other-sys", Some(&first)),
3291 );
3292 }
3293
3294 #[test]
3295 fn body_carries_prompt_cache_key() {
3296 let body = body_for_level(
3297 "gpt-5.6-sol",
3298 ReasoningLevel::Medium,
3299 serde_json::json!([]),
3300 "sys".to_string(),
3301 vec![],
3302 None,
3303 None,
3304 );
3305 assert_eq!(
3306 body.get("prompt_cache_key")
3307 .and_then(serde_json::Value::as_str),
3308 Some("synaps-test-cache-key")
3309 );
3310 }
3311
3312 #[test]
3313 fn worker_ultra_has_no_mode_context_item() {
3314 let plan = plan(ReasoningLevel::Ultra, CodexRequestRole::Worker);
3315 assert!(super::codex_multi_agent_mode_item(&plan).is_none());
3316 }
3317
3318 #[test]
3321 fn body_lowers_ultra_reasoning_effort_to_max() {
3322 let body = body_for_level(
3323 "gpt-5.6-sol",
3324 ReasoningLevel::Ultra,
3325 serde_json::json!([]),
3326 "sys".to_string(),
3327 vec![],
3328 None,
3329 None,
3330 );
3331 let effort = body
3332 .get("reasoning")
3333 .and_then(|r| r.get("effort"))
3334 .and_then(serde_json::Value::as_str)
3335 .expect("reasoning.effort must be present for Ultra");
3336 assert_eq!(effort, "max");
3337 }
3338
3339 #[test]
3340 fn body_contains_reasoning_effort_for_max() {
3341 let body = body_for_level(
3342 "gpt-5.6-sol",
3343 ReasoningLevel::Max,
3344 serde_json::json!([]),
3345 "sys".to_string(),
3346 vec![],
3347 None,
3348 None,
3349 );
3350 let effort = body
3351 .get("reasoning")
3352 .and_then(|r| r.get("effort"))
3353 .and_then(serde_json::Value::as_str)
3354 .expect("reasoning.effort must be present for Max");
3355 assert_eq!(effort, "max");
3356 assert_ne!(effort, "xhigh");
3358 }
3359
3360 #[test]
3361 fn body_contains_reasoning_effort_for_xhigh() {
3362 let body = body_for_level(
3363 "gpt-5.6-sol",
3364 ReasoningLevel::XHigh,
3365 serde_json::json!([]),
3366 "sys".to_string(),
3367 vec![],
3368 None,
3369 None,
3370 );
3371 let effort = body
3372 .get("reasoning")
3373 .and_then(|r| r.get("effort"))
3374 .and_then(serde_json::Value::as_str)
3375 .expect("reasoning.effort must be present for XHigh");
3376 assert_eq!(effort, "xhigh");
3377 }
3378
3379 #[test]
3380 fn body_contains_reasoning_effort_for_low_medium_high() {
3381 for (level, expected) in [
3382 (ReasoningLevel::Low, "low"),
3383 (ReasoningLevel::Medium, "medium"),
3384 (ReasoningLevel::High, "high"),
3385 ] {
3386 let body = body_for_level(
3387 "gpt-5.6-sol",
3388 level,
3389 serde_json::json!([]),
3390 "sys".to_string(),
3391 vec![],
3392 None,
3393 None,
3394 );
3395 let effort = body
3396 .get("reasoning")
3397 .and_then(|r| r.get("effort"))
3398 .and_then(serde_json::Value::as_str)
3399 .unwrap_or_else(|| panic!("{level:?} must emit reasoning.effort"));
3400 assert_eq!(effort, expected, "{level:?}");
3401 }
3402 }
3403
3404 #[test]
3405 fn body_always_sets_model_and_stream_and_include() {
3406 let body = body_for_level(
3407 "gpt-5.5",
3408 ReasoningLevel::XHigh,
3409 serde_json::json!([]),
3410 "instructions".to_string(),
3411 vec![],
3412 None,
3413 None,
3414 );
3415 assert_eq!(
3416 body.get("model").and_then(serde_json::Value::as_str),
3417 Some("gpt-5.5")
3418 );
3419 assert_eq!(
3420 body.get("stream").and_then(serde_json::Value::as_bool),
3421 Some(true)
3422 );
3423 let includes = body.get("include").expect("include must be present");
3424 assert!(
3425 includes.as_array().is_some_and(|a| {
3426 a.iter()
3427 .any(|v| v.as_str() == Some("reasoning.encrypted_content"))
3428 }),
3429 "include must contain reasoning.encrypted_content"
3430 );
3431 assert_eq!(
3432 body.get("store").and_then(serde_json::Value::as_bool),
3433 Some(false)
3434 );
3435 }
3436
3437 #[test]
3438 fn body_omits_temperature_and_max_tokens_when_none() {
3439 let body = body_for_level(
3440 "gpt-5.6-sol",
3441 ReasoningLevel::Medium,
3442 serde_json::json!([]),
3443 "sys".to_string(),
3444 vec![],
3445 None,
3446 None,
3447 );
3448 assert!(body.get("temperature").is_none());
3449 assert!(body.get("max_output_tokens").is_none());
3450 }
3451
3452 #[test]
3453 fn body_includes_temperature_and_max_tokens_when_some() {
3454 let body = body_for_level(
3455 "gpt-5.6-sol",
3456 ReasoningLevel::High,
3457 serde_json::json!([]),
3458 "sys".to_string(),
3459 vec![],
3460 Some(0.7),
3461 Some(2048),
3462 );
3463 let temp = body
3464 .get("temperature")
3465 .and_then(serde_json::Value::as_f64)
3466 .unwrap();
3467 assert!((temp - 0.7f64).abs() < 0.01, "temperature={temp}");
3468 assert_eq!(
3469 body.get("max_output_tokens")
3470 .and_then(serde_json::Value::as_u64),
3471 Some(2048)
3472 );
3473 }
3474}
3475
3476#[cfg(test)]
3477mod send_retry_tests {
3478 use super::*;
3488 use agent_core::auth::{
3489 AccessToken, BrokerError, ProxyByteStream, ProxyRequest, ProxyResponse,
3490 };
3491 use agent_core::reasoning::ReasoningLevel;
3492 use async_trait::async_trait;
3493 use axum::{http::StatusCode, response::IntoResponse, routing::post as axum_post, Router};
3494 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
3495 use std::sync::{
3496 atomic::{AtomicUsize, Ordering},
3497 Arc,
3498 };
3499 use tokio::sync::mpsc;
3500
3501 const CODEX_SSE_SUCCESS: &str = concat!(
3503 "data: {\"type\":\"response.output_text.delta\",\"delta\":\"hello\"}\n\n",
3504 "data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n",
3505 "data: [DONE]\n\n",
3506 );
3507
3508 fn fake_codex_token() -> String {
3512 let payload = serde_json::json!({
3513 "https://api.openai.com/auth": { "chatgpt_account_id": "acct_test" }
3514 });
3515 format!("h.{}.s", URL_SAFE_NO_PAD.encode(payload.to_string()))
3516 }
3517
3518 struct TokenOnlyBroker;
3521
3522 #[async_trait]
3523 impl crate::auth::CredentialBroker for TokenOnlyBroker {
3524 async fn access_token(
3525 &self,
3526 _p: agent_core::auth::OAuthProviderId,
3527 ) -> Result<AccessToken, BrokerError> {
3528 Ok(AccessToken {
3529 token: fake_codex_token(),
3530 expires: u64::MAX,
3531 })
3532 }
3533 async fn proxy(&self, _request: ProxyRequest) -> Result<ProxyResponse, BrokerError> {
3534 Err(BrokerError::Denied("not implemented in stub".into()))
3535 }
3536 async fn proxy_stream(
3537 &self,
3538 _request: ProxyRequest,
3539 ) -> Result<ProxyByteStream, BrokerError> {
3540 Err(BrokerError::Denied("not implemented in stub".into()))
3541 }
3542 async fn anthropic_usage(&self) -> Result<serde_json::Value, BrokerError> {
3543 Err(BrokerError::Denied("not implemented in stub".into()))
3544 }
3545 async fn capabilities(&self) -> Result<Vec<agent_core::auth::ProviderStatus>, BrokerError> {
3546 Ok(vec![])
3547 }
3548 }
3549
3550 struct DenyingCountingBroker {
3551 access_calls: Arc<AtomicUsize>,
3552 }
3553
3554 #[async_trait]
3555 impl crate::auth::CredentialBroker for DenyingCountingBroker {
3556 async fn access_token(
3557 &self,
3558 _provider: agent_core::auth::OAuthProviderId,
3559 ) -> Result<AccessToken, BrokerError> {
3560 self.access_calls.fetch_add(1, Ordering::SeqCst);
3561 Err(BrokerError::Denied(
3562 "credential access must not occur".into(),
3563 ))
3564 }
3565
3566 async fn proxy(&self, _request: ProxyRequest) -> Result<ProxyResponse, BrokerError> {
3567 Err(BrokerError::Denied("not implemented in stub".into()))
3568 }
3569
3570 async fn proxy_stream(
3571 &self,
3572 _request: ProxyRequest,
3573 ) -> Result<ProxyByteStream, BrokerError> {
3574 Err(BrokerError::Denied("not implemented in stub".into()))
3575 }
3576
3577 async fn anthropic_usage(&self) -> Result<serde_json::Value, BrokerError> {
3578 Err(BrokerError::Denied("not implemented in stub".into()))
3579 }
3580
3581 async fn capabilities(&self) -> Result<Vec<agent_core::auth::ProviderStatus>, BrokerError> {
3582 Ok(vec![])
3583 }
3584 }
3585
3586 async fn spawn_mock_codex(
3589 fail_count: usize,
3590 fail_status: StatusCode,
3591 ) -> (String, Arc<AtomicUsize>) {
3592 let counter = Arc::new(AtomicUsize::new(0));
3593 let counter_clone = Arc::clone(&counter);
3594 let app = Router::new().route(
3595 "/codex/responses",
3596 axum_post(move || {
3597 let counter = Arc::clone(&counter_clone);
3598 async move {
3599 let n = counter.fetch_add(1, Ordering::SeqCst);
3600 if n < fail_count {
3601 (
3602 fail_status,
3603 [("content-type", "application/json")],
3604 "{\"error\":{\"message\":\"transient upstream sadness\"}}".to_string(),
3605 )
3606 .into_response()
3607 } else {
3608 (
3609 StatusCode::OK,
3610 [("content-type", "text/event-stream")],
3611 CODEX_SSE_SUCCESS.to_string(),
3612 )
3613 .into_response()
3614 }
3615 }
3616 }),
3617 );
3618 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3619 let addr = listener.local_addr().unwrap();
3620 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
3621 (format!("http://{addr}"), counter)
3622 }
3623
3624 async fn spawn_codex_sse(body: &'static str) -> String {
3625 let app = Router::new().route(
3626 "/codex/responses",
3627 axum_post(move || async move {
3628 (
3629 StatusCode::OK,
3630 [("content-type", "text/event-stream")],
3631 body,
3632 )
3633 }),
3634 );
3635 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3636 let addr = listener.local_addr().unwrap();
3637 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
3638 format!("http://{addr}")
3639 }
3640
3641 async fn spawn_empty_then_success_codex() -> (
3642 String,
3643 Arc<AtomicUsize>,
3644 Arc<std::sync::Mutex<Vec<bytes::Bytes>>>,
3645 ) {
3646 let counter = Arc::new(AtomicUsize::new(0));
3647 let bodies = Arc::new(std::sync::Mutex::new(Vec::new()));
3648 let app = Router::new().route(
3649 "/codex/responses",
3650 axum_post({
3651 let counter = Arc::clone(&counter);
3652 let bodies = Arc::clone(&bodies);
3653 move |body: bytes::Bytes| {
3654 let counter = Arc::clone(&counter);
3655 let bodies = Arc::clone(&bodies);
3656 async move {
3657 bodies.lock().unwrap().push(body);
3658 let n = counter.fetch_add(1, Ordering::SeqCst);
3659 let response = if n == 0 {
3660 concat!(
3661 "data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":7,\"output_tokens\":0}}}\n\n",
3662 "data: [DONE]\n\n",
3663 )
3664 } else {
3665 CODEX_SSE_SUCCESS
3666 };
3667 (
3668 StatusCode::OK,
3669 [("content-type", "text/event-stream")],
3670 response,
3671 )
3672 }
3673 }
3674 }),
3675 );
3676 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3677 let addr = listener.local_addr().unwrap();
3678 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
3679 (format!("http://{addr}"), counter, bodies)
3680 }
3681
3682 async fn run_codex(
3683 base_url: &str,
3684 max_retries: u32,
3685 ) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
3686 let cfg = ProviderConfig {
3687 base_url: base_url.to_string(),
3688 model: "gpt-5.6-sol".to_string(),
3690 provider: "openai-codex".to_string(),
3691 };
3692 let client = reqwest::Client::new();
3693 let broker: std::sync::Arc<dyn crate::auth::CredentialBroker> =
3694 std::sync::Arc::new(TokenOnlyBroker);
3695 let (tx, _rx) = mpsc::unbounded_channel();
3696 call_codex_stream_inner(
3697 &cfg,
3698 &client,
3699 &broker,
3700 &[],
3701 &Some("test".to_string()),
3702 &[],
3703 &tx,
3704 None,
3705 None,
3706 agent_core::reasoning::ReasoningLevel::Medium,
3707 crate::runtime::openai::catalog::CodexRequestRole::Foreground,
3708 &tokio_util::sync::CancellationToken::new(),
3709 max_retries,
3710 &crate::runtime::trace::TraceContext::disabled(),
3711 )
3712 .await
3713 }
3714
3715 #[tokio::test]
3716 async fn direct_ultra_tool_guard_precedes_broker_access() {
3717 let access_calls = Arc::new(AtomicUsize::new(0));
3718 let broker: Arc<dyn crate::auth::CredentialBroker> = Arc::new(DenyingCountingBroker {
3719 access_calls: Arc::clone(&access_calls),
3720 });
3721 let cfg = ProviderConfig {
3722 base_url: "http://127.0.0.1:1".to_string(),
3723 model: "gpt-5.6-sol".to_string(),
3724 provider: "openai-codex".to_string(),
3725 };
3726 let (tx, _rx) = mpsc::unbounded_channel();
3727
3728 let error = call_codex_stream_inner(
3729 &cfg,
3730 &reqwest::Client::new(),
3731 &broker,
3732 &[],
3733 &Some("test".to_string()),
3734 &[],
3735 &tx,
3736 None,
3737 None,
3738 ReasoningLevel::Ultra,
3739 crate::runtime::openai::catalog::CodexRequestRole::Foreground,
3740 &tokio_util::sync::CancellationToken::new(),
3741 0,
3742 &crate::runtime::trace::TraceContext::disabled(),
3743 )
3744 .await
3745 .expect_err("foreground Ultra without delegation tools must fail closed");
3746
3747 assert!(error.to_string().contains("subagent_start"), "{error}");
3748 assert_eq!(
3749 access_calls.load(Ordering::SeqCst),
3750 0,
3751 "preflight denial must happen before broker credential access"
3752 );
3753 }
3754
3755 #[tokio::test]
3756 async fn direct_codex_path_rejects_provider_identity_before_broker_access() {
3757 let access_calls = Arc::new(AtomicUsize::new(0));
3758 let broker: Arc<dyn crate::auth::CredentialBroker> = Arc::new(DenyingCountingBroker {
3759 access_calls: Arc::clone(&access_calls),
3760 });
3761 let cfg = ProviderConfig {
3762 base_url: "http://127.0.0.1:1".to_string(),
3763 model: "gpt-5.6-sol".to_string(),
3764 provider: "openrouter".to_string(),
3765 };
3766 let (tx, _rx) = mpsc::unbounded_channel();
3767
3768 let error = call_codex_stream_inner(
3769 &cfg,
3770 &reqwest::Client::new(),
3771 &broker,
3772 &[],
3773 &Some("test".to_string()),
3774 &[],
3775 &tx,
3776 None,
3777 None,
3778 ReasoningLevel::Medium,
3779 crate::runtime::openai::catalog::CodexRequestRole::Foreground,
3780 &tokio_util::sync::CancellationToken::new(),
3781 0,
3782 &crate::runtime::trace::TraceContext::disabled(),
3783 )
3784 .await
3785 .expect_err("the Codex seam must validate provider-qualified identity");
3786
3787 assert!(error.to_string().contains("openrouter"), "{error}");
3788 assert_eq!(
3789 access_calls.load(Ordering::SeqCst),
3790 0,
3791 "provider identity denial must precede broker credential access"
3792 );
3793 }
3794
3795 #[tokio::test]
3801 async fn codex_response_failed_event_is_a_terminal_error_not_empty_success() {
3802 let base_url = spawn_codex_sse(concat!(
3803 "data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"type\":\"server_error\",\"message\":\"ECHOED:secret prompt\"}}}\n\n",
3804 "data: [DONE]\n\n",
3805 ))
3806 .await;
3807 let err = run_codex(&base_url, 0)
3808 .await
3809 .expect_err("response.failed must fail the request");
3810 let msg = err.to_string();
3811 assert!(
3812 msg.starts_with("Codex response failed in stream."),
3813 "static failure message expected: {msg}"
3814 );
3815 assert!(
3816 !msg.contains("ECHOED") && !msg.contains("secret prompt"),
3817 "provider error body must never surface: {msg}"
3818 );
3819 }
3820
3821 #[tokio::test]
3824 async fn codex_response_incomplete_event_is_a_terminal_error() {
3825 let base_url = spawn_codex_sse(concat!(
3826 "data: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\n\n",
3827 "data: {\"type\":\"response.incomplete\",\"response\":{\"incomplete_details\":{\"reason\":\"max_output_tokens\"}}}\n\n",
3828 "data: [DONE]\n\n",
3829 ))
3830 .await;
3831 let err = run_codex(&base_url, 0)
3832 .await
3833 .expect_err("response.incomplete must fail the request");
3834 assert!(
3835 err.to_string()
3836 .starts_with("Codex response was incomplete."),
3837 "got: {err}"
3838 );
3839 }
3840
3841 #[tokio::test]
3844 async fn codex_eof_without_terminal_event_fails_closed() {
3845 let base_url = spawn_codex_sse(
3846 "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\"}}\n\n",
3847 )
3848 .await;
3849 let err = run_codex(&base_url, 0)
3850 .await
3851 .expect_err("EOF without terminal event must fail");
3852 assert!(
3853 err.to_string()
3854 .starts_with("Codex response stream ended without a terminal event."),
3855 "got: {err}"
3856 );
3857 }
3858
3859 #[tokio::test]
3860 async fn codex_completed_without_output_retries_identical_bytes_then_succeeds() {
3861 let (base_url, counter, bodies) = spawn_empty_then_success_codex().await;
3862 let result = run_codex(&base_url, 1)
3863 .await
3864 .expect("one empty-success stream with retries available must not abort the turn");
3865
3866 assert_eq!(counter.load(Ordering::SeqCst), 2, "expected one retry");
3867 let received = bodies.lock().unwrap();
3868 assert_eq!(received.len(), 2, "both attempts must reach the endpoint");
3869 assert_eq!(
3870 received[0], received[1],
3871 "the logical request must resend byte-identical content"
3872 );
3873 assert_eq!(result["content"][0]["text"], "hello");
3874 }
3875
3876 #[tokio::test]
3877 async fn codex_retries_transient_500_then_succeeds() {
3878 let (base_url, counter) = spawn_mock_codex(1, StatusCode::INTERNAL_SERVER_ERROR).await;
3879 let result = run_codex(&base_url, 2)
3880 .await
3881 .expect("one transient 500 with retries available must not abort the turn");
3882 assert_eq!(
3883 counter.load(Ordering::SeqCst),
3884 2,
3885 "expected exactly one retry"
3886 );
3887 let text = result["content"][0]["text"].as_str().unwrap_or_default();
3888 assert_eq!(text, "hello");
3889 }
3890
3891 #[tokio::test]
3892 async fn codex_response_failed_surfaces_typed_provider_error_not_empty_response() {
3893 const BODY: &str = concat!(
3894 "data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"type\":\"server_error\",\"message\":\"ECHOED:private prompt\"}}}\n\n",
3895 "data: [DONE]\n\n",
3896 );
3897 let base_url = spawn_codex_sse(BODY).await;
3898 let err = run_codex(&base_url, 0).await.expect_err("must fail");
3899 let msg = err.to_string();
3900 assert!(msg.starts_with("Codex response failed in stream."), "{msg}");
3901 assert!(!msg.contains("empty response"), "{msg}");
3902 assert!(!msg.contains("ECHOED"), "provider text leaked: {msg}");
3903 assert!(
3904 !msg.contains("private prompt"),
3905 "provider text leaked: {msg}"
3906 );
3907 }
3908
3909 #[tokio::test]
3910 async fn codex_response_incomplete_surfaces_typed_provider_error_not_empty_response() {
3911 const BODY: &str = concat!(
3912 "data: {\"type\":\"response.incomplete\",\"response\":{\"incomplete_details\":{\"reason\":\"max_output_tokens\"}}}\n\n",
3913 "data: [DONE]\n\n",
3914 );
3915 let base_url = spawn_codex_sse(BODY).await;
3916 let err = run_codex(&base_url, 0).await.expect_err("must fail");
3917 let msg = err.to_string();
3918 assert!(msg.starts_with("Codex response was incomplete."), "{msg}");
3919 assert!(!msg.contains("empty response"), "{msg}");
3920 assert!(
3921 !msg.contains("max_output_tokens"),
3922 "provider text leaked: {msg}"
3923 );
3924 }
3925
3926 #[tokio::test]
3927 async fn codex_clean_eof_without_terminal_fails_closed() {
3928 let base_url = spawn_codex_sse(
3929 "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_123\"}}\n\n",
3930 )
3931 .await;
3932 let err = run_codex(&base_url, 0).await.expect_err("must fail");
3933 assert!(
3934 err.to_string()
3935 .starts_with("Codex response stream ended without a terminal event."),
3936 "{err}"
3937 );
3938 }
3939
3940 #[tokio::test]
3941 async fn codex_retries_429_then_succeeds() {
3942 let (base_url, counter) = spawn_mock_codex(1, StatusCode::TOO_MANY_REQUESTS).await;
3943 run_codex(&base_url, 2)
3944 .await
3945 .expect("429 is transient — must retry");
3946 assert_eq!(counter.load(Ordering::SeqCst), 2);
3947 }
3948
3949 #[tokio::test]
3950 async fn codex_zero_retries_fails_fast_with_status_error() {
3951 let (base_url, counter) =
3952 spawn_mock_codex(usize::MAX, StatusCode::INTERNAL_SERVER_ERROR).await;
3953 let err = run_codex(&base_url, 0).await.expect_err("must fail");
3954 assert_eq!(counter.load(Ordering::SeqCst), 1, "no retries budgeted");
3955 let msg = err.to_string();
3956 assert!(
3957 msg.starts_with("codex request failed: 500"),
3958 "status must survive for classification: {msg}"
3959 );
3960 }
3961
3962 #[tokio::test]
3963 async fn codex_does_not_retry_client_errors() {
3964 let (base_url, counter) = spawn_mock_codex(usize::MAX, StatusCode::BAD_REQUEST).await;
3965 let err = run_codex(&base_url, 3).await.expect_err("must fail");
3966 assert_eq!(
3967 counter.load(Ordering::SeqCst),
3968 1,
3969 "400 is deterministic — retrying it never helps"
3970 );
3971 assert!(err.to_string().starts_with("codex request failed: 400"));
3972 }
3973
3974 #[tokio::test]
3975 async fn codex_exhausted_retries_reports_last_status() {
3976 let (base_url, counter) =
3977 spawn_mock_codex(usize::MAX, StatusCode::SERVICE_UNAVAILABLE).await;
3978 let err = run_codex(&base_url, 1).await.expect_err("must fail");
3979 assert_eq!(
3980 counter.load(Ordering::SeqCst),
3981 2,
3982 "initial attempt + 1 retry"
3983 );
3984 assert!(err.to_string().starts_with("codex request failed: 503"));
3985 }
3986
3987 #[test]
3991 fn codex_retry_budget_lifts_configured_default_to_persistent_floor() {
3992 assert_eq!(codex_retry_budget(3), CODEX_PERSISTENT_RETRIES);
3993 assert_eq!(codex_retry_budget(0), CODEX_PERSISTENT_RETRIES);
3994 assert_eq!(codex_retry_budget(10), 10);
3995 }
3996
3997 #[test]
4000 fn codex_retry_budget_honors_larger_user_configuration() {
4001 assert_eq!(codex_retry_budget(15), 15);
4002 }
4003
4004 #[test]
4007 fn retry_delay_is_exponential_with_a_capped_tail() {
4008 assert_eq!(retry_delay(1).as_secs(), 1);
4009 assert_eq!(retry_delay(2).as_secs(), 2);
4010 assert_eq!(retry_delay(4).as_secs(), 8);
4011 assert_eq!(retry_delay(7).as_secs(), 64);
4012 assert_eq!(retry_delay(10).as_secs(), 64);
4013 }
4014
4015 const ECHO_SENTINEL: &str = "PH1-OAI-SENTINEL-2b8c4d-RAW-CONTENT";
4023
4024 struct CaptureWriter(Arc<std::sync::Mutex<Vec<u8>>>);
4026
4027 impl std::io::Write for CaptureWriter {
4028 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
4029 self.0.lock().unwrap().extend_from_slice(buf);
4030 Ok(buf.len())
4031 }
4032 fn flush(&mut self) -> std::io::Result<()> {
4033 Ok(())
4034 }
4035 }
4036
4037 fn capture_subscriber(
4038 buf: &Arc<std::sync::Mutex<Vec<u8>>>,
4039 ) -> impl tracing::Subscriber + Send + Sync {
4040 let sink = buf.clone();
4041 tracing_subscriber::fmt()
4042 .with_max_level(tracing::Level::TRACE)
4043 .with_writer(move || CaptureWriter(sink.clone()))
4044 .with_ansi(false)
4045 .finish()
4046 }
4047
4048 async fn spawn_hostile_echo_provider(status: StatusCode) -> String {
4052 let app = Router::new().fallback(move |body: String| async move {
4053 let envelope = serde_json::json!({
4054 "error": { "message": format!("ECHOED:{body}") }
4055 });
4056 (
4057 status,
4058 [("content-type", "application/json")],
4059 envelope.to_string(),
4060 )
4061 .into_response()
4062 });
4063 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4064 let addr = listener.local_addr().unwrap();
4065 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
4066 format!("http://{addr}")
4067 }
4068
4069 fn sentinel_request_body() -> Value {
4073 json!({
4074 "model": "gpt-test",
4075 "messages": [
4076 {"role": "system", "content": format!("system secret {ECHO_SENTINEL}")},
4077 {"role": "user", "content": ECHO_SENTINEL},
4078 ],
4079 "tools": [{
4080 "type": "function",
4081 "function": {
4082 "name": "ph1_secret_tool_zz",
4083 "description": "internal tool schema",
4084 "parameters": {"type": "object", "properties": {}}
4085 }
4086 }],
4087 })
4088 }
4089
4090 const BANNED_MARKERS: &[&str] = &[
4092 "ECHOED",
4093 ECHO_SENTINEL,
4094 "ph1_secret_tool_zz",
4095 "\"messages\"",
4096 "system secret",
4097 ];
4098
4099 fn assert_no_banned(haystack: &str, context: &str) {
4100 for banned in BANNED_MARKERS {
4101 assert!(
4102 !haystack.contains(banned),
4103 "provider body content `{banned}` leaked into {context}: {haystack}"
4104 );
4105 }
4106 }
4107
4108 #[tokio::test]
4109 async fn send_with_retries_non_retryable_error_never_surfaces_provider_body() {
4110 let url = spawn_hostile_echo_provider(StatusCode::BAD_REQUEST).await;
4111 let client = reqwest::Client::new();
4112 let body = sentinel_request_body();
4113
4114 let buf = Arc::new(std::sync::Mutex::new(Vec::new()));
4115 let _guard = tracing::subscriber::set_default(capture_subscriber(&buf));
4116
4117 let err = send_with_retries(
4118 "codex",
4119 &url,
4120 || client.post(&url).json(&body),
4121 &tokio_util::sync::CancellationToken::new(),
4122 3,
4123 &mut tr::StreamAttempt::new(None),
4124 )
4125 .await
4126 .expect_err("400 must fail fast");
4127
4128 let msg = err.to_string();
4129 assert!(
4130 msg.starts_with("codex request failed: 400"),
4131 "status + label must survive for classification/usability: {msg}"
4132 );
4133 assert_no_banned(&msg, "the surfaced error");
4134 let logs = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
4135 assert_no_banned(&logs, "tracing output");
4136 }
4137
4138 #[tokio::test(start_paused = true)]
4139 async fn send_with_retries_retry_logs_never_contain_provider_body() {
4140 let url = spawn_hostile_echo_provider(StatusCode::SERVICE_UNAVAILABLE).await;
4141 let client = reqwest::Client::new();
4142 let body = sentinel_request_body();
4143
4144 let buf = Arc::new(std::sync::Mutex::new(Vec::new()));
4145 let _guard = tracing::subscriber::set_default(capture_subscriber(&buf));
4146
4147 let err = send_with_retries(
4148 "codex",
4149 &url,
4150 || client.post(&url).json(&body),
4151 &tokio_util::sync::CancellationToken::new(),
4152 1,
4153 &mut tr::StreamAttempt::new(None),
4154 )
4155 .await
4156 .expect_err("persistent 503 must exhaust the budget");
4157
4158 let msg = err.to_string();
4159 assert!(
4160 msg.starts_with("codex request failed: 503"),
4161 "status + label must survive after exhaustion: {msg}"
4162 );
4163 assert_no_banned(&msg, "the surfaced error");
4164
4165 let logs = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
4166 assert!(
4167 logs.contains("retry 1/1"),
4168 "retry accounting must stay observable: {logs}"
4169 );
4170 assert!(
4171 logs.contains("503"),
4172 "retry log must keep the status for diagnosis: {logs}"
4173 );
4174 assert_no_banned(&logs, "retry tracing output");
4175 }
4176}