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, Copy, Debug, Eq, PartialEq)]
887struct ResponsesStreamFailure {
888 code: &'static str,
889 message: &'static str,
890}
891
892#[derive(Default)]
893struct CodexSseDecoder {
894 buffer: String,
895 active_tools: Vec<CodexToolAccumulator>,
896 completed_tools: Vec<ToolCall>,
897 saw_model_event: bool,
900 terminal_success: bool,
904 terminal_failure: Option<ResponsesStreamFailure>,
905 emitted_output: bool,
906 observed_usage: Option<(u64, u64, u64)>,
909}
910
911#[derive(Default)]
912struct CodexToolAccumulator {
913 id: String,
914 name: String,
915 arguments: String,
916 started: bool,
917}
918
919fn parse_tool_arguments(raw: &str) -> Value {
927 if raw.trim().is_empty() {
928 return json!({});
929 }
930 match serde_json::from_str(raw) {
931 Ok(v) => v,
932 Err(e) => json!({ "__parse_error": format!("invalid tool input JSON: {}", e) }),
933 }
934}
935
936fn sanitize_error_identifier(raw: Option<&str>) -> &str {
945 match raw {
946 None => "absent",
947 Some(s) => {
948 let identifier_shaped = !s.is_empty()
949 && s.len() <= 64
950 && s.bytes()
951 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.');
952 if identifier_shaped
953 && s.bytes()
954 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
955 {
956 s
958 } else if identifier_shaped {
959 "unlisted_identifier"
962 } else {
963 "unsafe_or_freeform"
964 }
965 }
966 }
967}
968
969fn user_message_for_terminal_failure(error_kind: &str, error_code: &str) -> &'static str {
975 if error_code == "context_length_exceeded" || error_kind == "context_length_exceeded" {
978 return "Codex rejected the request: the conversation exceeds this model's context window. Run /compact or start a fresh session to continue.";
979 }
980 "Codex response failed in stream. Provider error details withheld because they can echo request content."
981}
982
983impl CodexSseDecoder {
984 fn push_line(
985 &mut self,
986 line: &str,
987 tx: &mpsc::UnboundedSender<StreamEvent>,
988 text_acc: &mut String,
989 ) {
990 let line = line.trim_end_matches('\r');
991 if line.is_empty() {
992 if !self.buffer.is_empty() {
993 let payload = std::mem::take(&mut self.buffer);
994 self.push_payload(&payload, tx, text_acc);
995 }
996 return;
997 }
998 let Some(data) = line.strip_prefix("data:").map(str::trim_start) else {
999 return;
1000 };
1001 if data == "[DONE]" {
1002 self.terminal_success = true;
1003 self.finish();
1004 return;
1005 }
1006 self.buffer.push_str(data);
1007 }
1008
1009 fn push_payload(
1010 &mut self,
1011 payload: &str,
1012 tx: &mpsc::UnboundedSender<StreamEvent>,
1013 text_acc: &mut String,
1014 ) {
1015 let Ok(event) = serde_json::from_str::<Value>(payload) else {
1016 tracing::debug!(
1017 payload_bytes = payload.len(),
1018 "discarding malformed Responses SSE payload"
1019 );
1020 return;
1021 };
1022 let event_type = event
1023 .get("type")
1024 .and_then(Value::as_str)
1025 .unwrap_or_default();
1026 if matches!(
1027 event_type,
1028 "response.output_text.delta"
1029 | "response.output_item.added"
1030 | "response.function_call_arguments.delta"
1031 | "response.output_item.done"
1032 | "response.completed"
1033 | "response.done"
1034 | "response.failed"
1035 | "response.incomplete"
1036 | "error"
1037 ) {
1038 self.saw_model_event = true;
1039 }
1040 match event_type {
1041 "response.output_text.delta" => {
1042 if let Some(delta) = event.get("delta").and_then(Value::as_str) {
1043 if !delta.is_empty() {
1044 self.emitted_output = true;
1045 }
1046 text_acc.push_str(delta);
1047 let _ = tx.send(StreamEvent::Llm(crate::runtime::types::LlmEvent::Text(
1048 delta.to_string(),
1049 )));
1050 }
1051 }
1052 "response.output_item.added" => {
1053 if let Some(item) = event.get("item") {
1054 let idx = event
1055 .get("output_index")
1056 .and_then(Value::as_u64)
1057 .unwrap_or(0) as usize;
1058 self.add_tool_from_item(idx, item, tx);
1059 }
1060 }
1061 "response.function_call_arguments.delta" => {
1062 let idx = event
1063 .get("output_index")
1064 .and_then(Value::as_u64)
1065 .unwrap_or(0) as usize;
1066 let delta = event
1067 .get("delta")
1068 .and_then(Value::as_str)
1069 .unwrap_or_default();
1070 if !delta.is_empty() {
1071 let tool = self.ensure_tool(idx);
1072 tool.arguments.push_str(delta);
1073 let tool_id = tool.id.clone();
1074 let _ = tx.send(StreamEvent::Llm(
1075 crate::runtime::types::LlmEvent::ToolUseDelta {
1076 tool_id,
1077 delta: delta.to_string(),
1078 },
1079 ));
1080 }
1081 }
1082 "response.output_item.done" => {
1083 if let Some(item) = event.get("item") {
1084 let idx = event
1085 .get("output_index")
1086 .and_then(Value::as_u64)
1087 .unwrap_or(0) as usize;
1088 self.complete_tool_from_item(idx, item, tx);
1089 }
1090 }
1091 "response.completed" | "response.done" => {
1092 self.push_usage(&event, tx);
1093 self.terminal_success = true;
1094 self.finish();
1095 }
1096 "response.failed" | "error" => {
1097 self.push_usage(&event, tx);
1098 let error_obj = event
1103 .get("response")
1104 .and_then(|r| r.get("error"))
1105 .or_else(|| event.get("error"));
1106 let error_kind = sanitize_error_identifier(
1107 error_obj
1108 .and_then(|e| e.get("type"))
1109 .and_then(Value::as_str),
1110 );
1111 let error_code = sanitize_error_identifier(
1112 error_obj
1113 .and_then(|e| e.get("code"))
1114 .and_then(Value::as_str),
1115 );
1116 tracing::warn!(
1117 event_type,
1118 error_kind,
1119 error_code,
1120 "codex stream terminal failure (provider message withheld)"
1121 );
1122 self.terminal_failure = Some(ResponsesStreamFailure {
1123 code: "responses_failed",
1124 message: user_message_for_terminal_failure(error_kind, error_code),
1125 });
1126 self.finish();
1127 }
1128 "response.incomplete" => {
1129 self.push_usage(&event, tx);
1130 let reason = sanitize_error_identifier(
1131 event
1132 .get("response")
1133 .and_then(|r| r.get("incomplete_details"))
1134 .and_then(|d| d.get("reason"))
1135 .and_then(Value::as_str),
1136 );
1137 tracing::warn!(reason, "codex stream terminal incomplete");
1138 self.terminal_failure = Some(ResponsesStreamFailure {
1139 code: "responses_incomplete",
1140 message: "Codex response was incomplete. Retry the request or reduce the requested output/context size.",
1141 });
1142 self.finish();
1143 }
1144 _ => {
1145 if !event_type.is_empty() {
1146 tracing::debug!(event_type, "ignoring unknown Responses SSE event");
1147 }
1148 }
1149 }
1150 }
1151
1152 fn terminal_result(&self) -> Result<(), ResponsesStreamFailure> {
1153 if let Some(failure) = self.terminal_failure {
1154 return Err(failure);
1155 }
1156 if self.terminal_success {
1157 if self.emitted_output || !self.completed_tools.is_empty() {
1158 Ok(())
1159 } else {
1160 Err(ResponsesStreamFailure {
1161 code: "responses_empty",
1162 message: "Codex completed without text or tool output. Retry the request.",
1163 })
1164 }
1165 } else {
1166 Err(ResponsesStreamFailure {
1167 code: "responses_missing_terminal",
1168 message: "Codex response stream ended without a terminal event. Retry the request.",
1169 })
1170 }
1171 }
1172
1173 fn ensure_tool(&mut self, idx: usize) -> &mut CodexToolAccumulator {
1174 while self.active_tools.len() <= idx {
1175 self.active_tools.push(CodexToolAccumulator::default());
1176 }
1177 &mut self.active_tools[idx]
1178 }
1179
1180 fn add_tool_from_item(
1181 &mut self,
1182 idx: usize,
1183 item: &Value,
1184 tx: &mpsc::UnboundedSender<StreamEvent>,
1185 ) {
1186 if item.get("type").and_then(Value::as_str) != Some("function_call") {
1187 return;
1188 }
1189 let tool = self.ensure_tool(idx);
1190 if let Some(id) = item
1191 .get("call_id")
1192 .or_else(|| item.get("id"))
1193 .and_then(Value::as_str)
1194 {
1195 tool.id = id.to_string();
1196 }
1197 if let Some(name) = item.get("name").and_then(Value::as_str) {
1198 tool.name = name.to_string();
1199 }
1200 if !tool.started && !tool.name.is_empty() {
1201 tool.started = true;
1202 let _ = tx.send(StreamEvent::Llm(
1203 crate::runtime::types::LlmEvent::ToolUseStart {
1204 tool_name: tool.name.clone(),
1205 tool_id: tool.id.clone(),
1206 },
1207 ));
1208 }
1209 }
1210
1211 fn complete_tool_from_item(
1212 &mut self,
1213 idx: usize,
1214 item: &Value,
1215 tx: &mpsc::UnboundedSender<StreamEvent>,
1216 ) {
1217 if item.get("type").and_then(Value::as_str) != Some("function_call") {
1218 return;
1219 }
1220 let tool = self.ensure_tool(idx);
1221 if let Some(id) = item
1222 .get("call_id")
1223 .or_else(|| item.get("id"))
1224 .and_then(Value::as_str)
1225 {
1226 tool.id = id.to_string();
1227 }
1228 if let Some(name) = item.get("name").and_then(Value::as_str) {
1229 tool.name = name.to_string();
1230 }
1231 if let Some(arguments) = item.get("arguments").and_then(Value::as_str) {
1232 tool.arguments = arguments.to_string();
1233 }
1234 if !tool.started && !tool.name.is_empty() {
1235 tool.started = true;
1236 let _ = tx.send(StreamEvent::Llm(
1237 crate::runtime::types::LlmEvent::ToolUseStart {
1238 tool_name: tool.name.clone(),
1239 tool_id: tool.id.clone(),
1240 },
1241 ));
1242 }
1243 let completed = if !tool.id.is_empty() && !tool.name.is_empty() {
1244 Some(ToolCall {
1245 id: tool.id.clone(),
1246 kind: "function".to_string(),
1247 function: super::types::FunctionCall {
1248 name: tool.name.clone(),
1249 arguments: tool.arguments.clone(),
1250 },
1251 })
1252 } else {
1253 None
1254 };
1255 if let Some(call) = completed {
1256 if self.completed_tools.iter().any(|done| done.id == call.id) {
1257 return;
1258 }
1259 self.emitted_output = true;
1260 let input = parse_tool_arguments(&call.function.arguments);
1268 let _ = tx.send(StreamEvent::Llm(crate::runtime::types::LlmEvent::ToolUse {
1269 tool_name: call.function.name.clone(),
1270 tool_id: call.id.clone(),
1271 input,
1272 }));
1273 self.completed_tools.push(ToolCall {
1274 id: call.id,
1275 kind: call.kind,
1276 function: call.function,
1277 });
1278 }
1279 }
1280
1281 fn push_usage(&mut self, event: &Value, tx: &mpsc::UnboundedSender<StreamEvent>) {
1282 let usage = event
1283 .get("response")
1284 .and_then(|r| r.get("usage"))
1285 .or_else(|| event.get("usage"));
1286 let input = usage
1287 .and_then(|u| u.get("input_tokens"))
1288 .and_then(Value::as_u64)
1289 .unwrap_or(0);
1290 let output = usage
1291 .and_then(|u| u.get("output_tokens"))
1292 .and_then(Value::as_u64)
1293 .unwrap_or(0);
1294 let cached = usage
1301 .and_then(|u| u.pointer("/input_tokens_details/cached_tokens"))
1302 .and_then(Value::as_u64)
1303 .unwrap_or(0)
1304 .min(input);
1305 if input > 0 || output > 0 {
1306 if self.observed_usage.is_none() {
1309 self.observed_usage = Some((input, output, cached));
1310 }
1311 let _ = tx.send(StreamEvent::Session(
1312 crate::runtime::types::SessionEvent::Usage {
1313 input_tokens: input - cached,
1314 output_tokens: output,
1315 cache_read_input_tokens: cached,
1316 cache_creation_input_tokens: 0,
1317 cache_creation_5m: None,
1318 cache_creation_1h: None,
1319 model: None,
1320 },
1321 ));
1322 }
1323 }
1324
1325 fn trace_usage(&self) -> Option<crate::runtime::trace::UsageMeta> {
1327 self.observed_usage
1328 .map(|(input, output, cached)| tr::provider_usage(input, output, cached))
1329 }
1330
1331 fn finish(&mut self) {
1332 for tool in self.active_tools.drain(..) {
1333 if !tool.id.is_empty()
1334 && !tool.name.is_empty()
1335 && !self.completed_tools.iter().any(|done| done.id == tool.id)
1336 {
1337 self.emitted_output = true;
1338 self.completed_tools.push(ToolCall {
1339 id: tool.id,
1340 kind: "function".to_string(),
1341 function: super::types::FunctionCall {
1342 name: tool.name,
1343 arguments: tool.arguments,
1344 },
1345 });
1346 }
1347 }
1348 }
1349}
1350
1351fn handle_events(
1352 events: &[OaiEvent],
1353 tx: &mpsc::UnboundedSender<StreamEvent>,
1354 text_acc: &mut String,
1355 tool_blocks: &mut Vec<Value>,
1356 name_map: &translate::ToolNameMap,
1357 suppress_stream_deltas: bool,
1358) {
1359 for ev in events {
1360 if let OaiEvent::TextDelta(t) = ev {
1361 text_acc.push_str(t);
1362 }
1363 if let OaiEvent::ToolCallsComplete { calls, .. } = ev {
1364 tool_blocks.extend(translate::tool_calls_to_content_blocks(calls, name_map));
1365 }
1366 if !suppress_stream_deltas {
1367 if let Some(se) = translate::oai_event_to_llm(ev) {
1368 let _ = tx.send(se);
1369 }
1370 }
1371 }
1372}
1373
1374#[cfg(test)]
1375mod codex_input_messages_tests {
1376 use super::super::types::{ChatMessage, FunctionCall, ToolCall};
1392 use super::*;
1393
1394 fn sample_tool_call() -> ToolCall {
1395 ToolCall {
1396 id: "call_nZYquCuGUh8Qs9H51dwHMDgs".to_string(),
1397 kind: "function".to_string(),
1398 function: FunctionCall {
1399 name: "bash".to_string(),
1400 arguments: r#"{"command":"ls"}"#.to_string(),
1401 },
1402 }
1403 }
1404
1405 #[test]
1406 fn codex_instructions_appends_autonomous_loop_policy() {
1407 let instructions = codex_instructions(&Some("Project-specific rules.".to_string()));
1408 assert!(instructions.contains("Project-specific rules."));
1409 assert!(instructions.contains("Do not stop at phase boundaries"));
1410 assert!(instructions.contains("Do not ask the user whether to continue"));
1411 assert!(
1412 instructions.contains("continue autonomously until the full requested job is complete")
1413 );
1414 }
1415
1416 #[test]
1417 fn function_call_input_omits_non_fc_id() {
1418 let messages = vec![ChatMessage::assistant_tool_calls(vec![sample_tool_call()])];
1419 let out = codex_input_messages(messages);
1420 assert_eq!(out.len(), 1, "one tool_call → one input item");
1421 let item = &out[0];
1422 assert_eq!(
1423 item.get("type").and_then(Value::as_str),
1424 Some("function_call")
1425 );
1426 assert!(
1427 item.get("id").is_none(),
1428 "must not echo a non-`fc_` id back; got {:?}",
1429 item.get("id"),
1430 );
1431 assert_eq!(
1432 item.get("call_id").and_then(Value::as_str),
1433 Some("call_nZYquCuGUh8Qs9H51dwHMDgs"),
1434 );
1435 assert_eq!(item.get("name").and_then(Value::as_str), Some("bash"));
1436 }
1437
1438 #[test]
1439 fn function_call_input_keeps_real_fc_id() {
1440 let mut call = sample_tool_call();
1443 call.id = "fc_abc123".to_string();
1444 let messages = vec![ChatMessage::assistant_tool_calls(vec![call])];
1445 let out = codex_input_messages(messages);
1446 let item = &out[0];
1447 assert_eq!(item.get("id").and_then(Value::as_str), Some("fc_abc123"));
1448 assert_eq!(
1449 item.get("call_id").and_then(Value::as_str),
1450 Some("fc_abc123")
1451 );
1452 }
1453
1454 #[test]
1455 fn function_call_output_round_trips_call_id() {
1456 let messages = vec![ChatMessage::tool_result(
1458 "call_nZYquCuGUh8Qs9H51dwHMDgs",
1459 "bash",
1460 "total 0",
1461 )];
1462 let out = codex_input_messages(messages);
1463 let item = &out[0];
1464 assert_eq!(
1465 item.get("type").and_then(Value::as_str),
1466 Some("function_call_output"),
1467 );
1468 assert_eq!(
1469 item.get("call_id").and_then(Value::as_str),
1470 Some("call_nZYquCuGUh8Qs9H51dwHMDgs"),
1471 );
1472 assert_eq!(item.get("output").and_then(Value::as_str), Some("total 0"));
1473 }
1474}
1475
1476#[cfg(test)]
1477mod codex_decoder_tests {
1478 use super::*;
1485 use crate::runtime::types::{LlmEvent, SessionEvent, StreamEvent};
1486
1487 fn collect_events(rx: &mut mpsc::UnboundedReceiver<StreamEvent>) -> Vec<StreamEvent> {
1488 let mut out = Vec::new();
1489 while let Ok(ev) = rx.try_recv() {
1490 out.push(ev);
1491 }
1492 out
1493 }
1494
1495 fn drive(lines: &[&str]) -> (CodexSseDecoder, String, Vec<StreamEvent>) {
1496 let (tx, mut rx) = mpsc::unbounded_channel();
1497 let mut decoder = CodexSseDecoder::default();
1498 let mut text_acc = String::new();
1499 for line in lines {
1500 decoder.push_line(line, &tx, &mut text_acc);
1501 }
1502 let events = collect_events(&mut rx);
1503 (decoder, text_acc, events)
1504 }
1505
1506 #[test]
1507 fn text_delta_aggregates_into_text_acc_and_emits_text_events() {
1508 let lines = [
1509 r#"data: {"type":"response.output_text.delta","delta":"Hello, "}"#,
1510 "",
1511 r#"data: {"type":"response.output_text.delta","delta":"world!"}"#,
1512 "",
1513 ];
1514 let (_decoder, text_acc, events) = drive(&lines);
1515 assert_eq!(text_acc, "Hello, world!");
1516 let texts: Vec<_> = events
1517 .iter()
1518 .filter_map(|e| match e {
1519 StreamEvent::Llm(LlmEvent::Text(t)) => Some(t.as_str()),
1520 _ => None,
1521 })
1522 .collect();
1523 assert_eq!(texts, vec!["Hello, ", "world!"]);
1524 }
1525
1526 #[test]
1527 fn single_function_call_completes_via_output_item_done() {
1528 let lines = [
1529 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_abc","name":"bash"}}"#,
1530 "",
1531 r#"data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"{\"cmd\""}"#,
1532 "",
1533 r#"data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":":\"ls\"}"}"#,
1534 "",
1535 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_abc","name":"bash","arguments":"{\"cmd\":\"ls\"}"}}"#,
1536 "",
1537 ];
1538 let (decoder, _text, events) = drive(&lines);
1539
1540 assert_eq!(decoder.completed_tools.len(), 1);
1541 let tool = &decoder.completed_tools[0];
1542 assert_eq!(tool.id, "call_abc");
1543 assert_eq!(tool.function.name, "bash");
1544 assert_eq!(tool.function.arguments, r#"{"cmd":"ls"}"#);
1545
1546 let starts: Vec<_> = events
1548 .iter()
1549 .filter_map(|e| match e {
1550 StreamEvent::Llm(LlmEvent::ToolUseStart { tool_name, tool_id }) => {
1551 Some((tool_name.as_str(), tool_id.as_str()))
1552 }
1553 _ => None,
1554 })
1555 .collect();
1556 assert_eq!(
1557 starts,
1558 vec![("bash", "call_abc")],
1559 "exactly one ToolUseStart with correct tool_id"
1560 );
1561
1562 let deltas: Vec<_> = events
1565 .iter()
1566 .filter_map(|e| match e {
1567 StreamEvent::Llm(LlmEvent::ToolUseDelta { tool_id, delta }) => {
1568 Some((tool_id.as_str(), delta.as_str()))
1569 }
1570 _ => None,
1571 })
1572 .collect();
1573 assert_eq!(
1574 deltas,
1575 vec![("call_abc", r#"{"cmd""#), ("call_abc", r#":"ls"}"#)]
1576 );
1577 }
1578
1579 #[test]
1580 fn parallel_tool_calls_indexed_separately() {
1581 let lines = [
1582 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"bash"}}"#,
1583 "",
1584 r#"data: {"type":"response.output_item.added","output_index":1,"item":{"type":"function_call","call_id":"call_2","name":"read"}}"#,
1585 "",
1586 r#"data: {"type":"response.function_call_arguments.delta","output_index":1,"delta":"{\"path\":\"a\"}"}"#,
1587 "",
1588 r#"data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"{\"cmd\":\"ls\"}"}"#,
1589 "",
1590 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"bash","arguments":"{\"cmd\":\"ls\"}"}}"#,
1591 "",
1592 r#"data: {"type":"response.output_item.done","output_index":1,"item":{"type":"function_call","call_id":"call_2","name":"read","arguments":"{\"path\":\"a\"}"}}"#,
1593 "",
1594 ];
1595 let (decoder, _text, _events) = drive(&lines);
1596
1597 assert_eq!(decoder.completed_tools.len(), 2);
1598 let mut by_id: std::collections::BTreeMap<&str, &ToolCall> =
1599 std::collections::BTreeMap::new();
1600 for tool in &decoder.completed_tools {
1601 by_id.insert(tool.id.as_str(), tool);
1602 }
1603 assert_eq!(by_id["call_1"].function.name, "bash");
1604 assert_eq!(by_id["call_1"].function.arguments, r#"{"cmd":"ls"}"#);
1605 assert_eq!(by_id["call_2"].function.name, "read");
1606 assert_eq!(by_id["call_2"].function.arguments, r#"{"path":"a"}"#);
1607 }
1608
1609 #[test]
1610 fn output_item_done_emits_tool_use_event() {
1611 let lines = [
1618 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_abc","name":"bash"}}"#,
1619 "",
1620 r#"data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"{\"command\":\"ls\"}"}"#,
1621 "",
1622 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_abc","name":"bash","arguments":"{\"command\":\"ls\"}"}}"#,
1623 "",
1624 ];
1625 let (_decoder, _text, events) = drive(&lines);
1626
1627 let tool_uses: Vec<_> = events
1628 .iter()
1629 .filter_map(|e| match e {
1630 StreamEvent::Llm(LlmEvent::ToolUse {
1631 tool_name,
1632 tool_id,
1633 input,
1634 }) => Some((tool_name.as_str(), tool_id.as_str(), input.clone())),
1635 _ => None,
1636 })
1637 .collect();
1638 assert_eq!(
1639 tool_uses.len(),
1640 1,
1641 "expected exactly one ToolUse finalize event"
1642 );
1643 assert_eq!(tool_uses[0].0, "bash");
1644 assert_eq!(tool_uses[0].1, "call_abc");
1645 assert_eq!(
1646 tool_uses[0].2,
1647 serde_json::json!({"command": "ls"}),
1648 "input must be parsed as a JSON Value, not a string"
1649 );
1650 }
1651
1652 #[test]
1653 fn parallel_tool_calls_emit_tool_use_per_index() {
1654 let lines = [
1658 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"bash"}}"#,
1659 "",
1660 r#"data: {"type":"response.output_item.added","output_index":1,"item":{"type":"function_call","call_id":"call_2","name":"read"}}"#,
1661 "",
1662 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"bash","arguments":"{\"command\":\"ls\"}"}}"#,
1663 "",
1664 r#"data: {"type":"response.output_item.done","output_index":1,"item":{"type":"function_call","call_id":"call_2","name":"read","arguments":"{\"path\":\"a\"}"}}"#,
1665 "",
1666 ];
1667 let (_decoder, _text, events) = drive(&lines);
1668
1669 let tool_uses: Vec<_> = events
1670 .iter()
1671 .filter_map(|e| match e {
1672 StreamEvent::Llm(LlmEvent::ToolUse {
1673 tool_name,
1674 tool_id,
1675 input,
1676 }) => Some((tool_name.clone(), tool_id.clone(), input.clone())),
1677 _ => None,
1678 })
1679 .collect();
1680
1681 assert_eq!(tool_uses.len(), 2, "one ToolUse finalize per parallel call");
1682 let by_id: std::collections::BTreeMap<&str, &(String, String, serde_json::Value)> =
1683 tool_uses.iter().map(|t| (t.1.as_str(), t)).collect();
1684 assert_eq!(by_id["call_1"].0, "bash");
1685 assert_eq!(by_id["call_1"].2, serde_json::json!({"command": "ls"}));
1686 assert_eq!(by_id["call_2"].0, "read");
1687 assert_eq!(by_id["call_2"].2, serde_json::json!({"path": "a"}));
1688 }
1689
1690 #[test]
1691 fn malformed_arguments_emit_tool_use_with_parse_error() {
1692 let lines = [
1697 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_bad","name":"bash"}}"#,
1698 "",
1699 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_bad","name":"bash","arguments":"{not json"}}"#,
1700 "",
1701 ];
1702 let (_decoder, _text, events) = drive(&lines);
1703
1704 let tool_use = events.iter().find_map(|e| match e {
1705 StreamEvent::Llm(LlmEvent::ToolUse { input, .. }) => Some(input.clone()),
1706 _ => None,
1707 });
1708 let input = tool_use.expect("ToolUse event missing");
1709 assert!(
1710 input.get("__parse_error").and_then(Value::as_str).is_some(),
1711 "malformed arguments must surface __parse_error, got {input}"
1712 );
1713 }
1714
1715 #[test]
1716 fn response_completed_emits_usage_event() {
1717 let lines = [
1718 r#"data: {"type":"response.completed","response":{"usage":{"input_tokens":42,"output_tokens":17}}}"#,
1719 "",
1720 ];
1721 let (_decoder, _text, events) = drive(&lines);
1722 let usage = events.iter().find_map(|e| match e {
1723 StreamEvent::Session(SessionEvent::Usage {
1724 input_tokens,
1725 output_tokens,
1726 ..
1727 }) => Some((*input_tokens, *output_tokens)),
1728 _ => None,
1729 });
1730 assert_eq!(usage, Some((42, 17)));
1731 }
1732
1733 #[test]
1739 fn response_completed_surfaces_cached_tokens_without_double_count() {
1740 let lines = [
1741 r#"data: {"type":"response.completed","response":{"usage":{"input_tokens":1000,"input_tokens_details":{"cached_tokens":900},"output_tokens":17}}}"#,
1742 "",
1743 ];
1744 let (_decoder, _text, events) = drive(&lines);
1745 let usage = events.iter().find_map(|e| match e {
1746 StreamEvent::Session(SessionEvent::Usage {
1747 input_tokens,
1748 output_tokens,
1749 cache_read_input_tokens,
1750 ..
1751 }) => Some((*input_tokens, *output_tokens, *cache_read_input_tokens)),
1752 _ => None,
1753 });
1754 assert_eq!(
1755 usage,
1756 Some((100, 17, 900)),
1757 "input must exclude the cached slice; cache_read must carry it"
1758 );
1759 }
1760
1761 #[test]
1762 fn response_completed_with_zero_usage_emits_nothing() {
1763 let lines = [
1764 r#"data: {"type":"response.output_text.delta","delta":"ok"}"#,
1765 "",
1766 r#"data: {"type":"response.completed","response":{"usage":{"input_tokens":0,"output_tokens":0}}}"#,
1767 "",
1768 ];
1769 let (decoder, _text, events) = drive(&lines);
1770 let any_usage = events
1771 .iter()
1772 .any(|e| matches!(e, StreamEvent::Session(SessionEvent::Usage { .. })));
1773 assert!(!any_usage, "zero-token usage should be suppressed");
1774 assert_eq!(decoder.terminal_result(), Ok(()));
1775 }
1776
1777 #[test]
1778 fn response_failed_is_a_terminal_failure_without_retaining_provider_text() {
1779 let lines = [
1780 r#"data: {"type":"response.failed","response":{"error":{"type":"server_error","message":"ECHOED:secret prompt"}}}"#,
1781 "",
1782 ];
1783 let (decoder, text, events) = drive(&lines);
1784 let failure = decoder.terminal_result().expect_err("must fail");
1785 assert_eq!(failure.code, "responses_failed");
1786 assert_eq!(
1787 failure.message,
1788 "Codex response failed in stream. Provider error details withheld because they can echo request content."
1789 );
1790 assert!(!failure.message.contains("ECHOED"));
1791 assert!(text.is_empty());
1792 assert!(events.is_empty());
1793 }
1794
1795 #[test]
1796 fn top_level_error_event_is_a_terminal_failure() {
1797 let lines = [
1798 r#"data: {"type":"error","error":{"type":"server_error","message":"private"}}"#,
1799 "",
1800 ];
1801 let (decoder, _text, _events) = drive(&lines);
1802 assert_eq!(
1803 decoder.terminal_result().expect_err("must fail").code,
1804 "responses_failed"
1805 );
1806 }
1807
1808 #[test]
1809 fn response_incomplete_is_not_misreported_as_empty_success() {
1810 let lines = [
1811 r#"data: {"type":"response.incomplete","response":{"incomplete_details":{"reason":"max_output_tokens"}}}"#,
1812 "",
1813 ];
1814 let (decoder, _text, _events) = drive(&lines);
1815 let failure = decoder.terminal_result().expect_err("must fail");
1816 assert_eq!(failure.code, "responses_incomplete");
1817 assert!(failure.message.contains("incomplete"));
1818 assert!(!failure.message.contains("max_output_tokens"));
1819 }
1820
1821 #[test]
1822 fn completed_without_text_or_tools_fails_at_transport_boundary() {
1823 let lines = [
1824 r#"data: {"type":"response.completed","response":{"usage":{"input_tokens":7,"output_tokens":0}}}"#,
1825 "",
1826 ];
1827 let (decoder, _text, _events) = drive(&lines);
1828 assert_eq!(
1829 decoder.terminal_result().expect_err("must fail").code,
1830 "responses_empty"
1831 );
1832 }
1833
1834 #[test]
1835 fn eof_without_terminal_event_fails_closed() {
1836 let lines = [
1837 r#"data: {"type":"response.created","response":{"id":"resp_123"}}"#,
1838 "",
1839 ];
1840 let (decoder, _text, _events) = drive(&lines);
1841 assert_eq!(
1842 decoder.terminal_result().expect_err("must fail").code,
1843 "responses_missing_terminal"
1844 );
1845 }
1846
1847 #[test]
1851 fn error_identifier_sanitizer_never_passes_freeform_text() {
1852 assert_eq!(sanitize_error_identifier(None), "absent");
1853 assert_eq!(
1854 sanitize_error_identifier(Some("rate_limit_exceeded")),
1855 "rate_limit_exceeded"
1856 );
1857 assert_eq!(
1858 sanitize_error_identifier(Some("context_length_exceeded")),
1859 "context_length_exceeded"
1860 );
1861 assert_eq!(
1862 sanitize_error_identifier(Some("some_new_code2")),
1863 "some_new_code2"
1864 );
1865 assert_eq!(
1867 sanitize_error_identifier(Some("sk-Abc123XYZ")),
1868 "unlisted_identifier"
1869 );
1870 assert_eq!(
1871 sanitize_error_identifier(Some("Server.Error")),
1872 "unlisted_identifier"
1873 );
1874 assert_eq!(
1875 sanitize_error_identifier(Some("ECHOED: user secret prompt")),
1876 "unsafe_or_freeform"
1877 );
1878 assert_eq!(sanitize_error_identifier(Some("")), "unsafe_or_freeform");
1879 let long = "a".repeat(65);
1880 assert_eq!(sanitize_error_identifier(Some(&long)), "unsafe_or_freeform");
1881 }
1882
1883 #[test]
1887 fn terminal_failure_user_message_is_specific_only_for_known_codes() {
1888 assert!(user_message_for_terminal_failure(
1889 "invalid_request_error",
1890 "context_length_exceeded"
1891 )
1892 .contains("/compact"));
1893 assert!(
1895 user_message_for_terminal_failure("context_length_exceeded", "absent")
1896 .contains("context window")
1897 );
1898 let default = user_message_for_terminal_failure("server_error", "unlisted_identifier");
1900 assert!(default.starts_with("Codex response failed in stream."));
1901 assert!(!default.contains("/compact"));
1902 }
1903
1904 #[test]
1905 fn done_marker_finishes_decoder() {
1906 let lines = [
1907 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_x","name":"bash"}}"#,
1908 "",
1909 r#"data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"{}"}"#,
1910 "",
1911 "data: [DONE]",
1912 "",
1913 ];
1914 let (decoder, _text, _events) = drive(&lines);
1915 assert_eq!(decoder.completed_tools.len(), 1);
1917 assert_eq!(decoder.completed_tools[0].id, "call_x");
1918 assert_eq!(decoder.completed_tools[0].function.arguments, "{}");
1919 }
1920
1921 #[test]
1922 fn finish_is_idempotent_no_double_emit() {
1923 let lines = [
1924 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_y","name":"bash","arguments":"{}"}}"#,
1925 "",
1926 ];
1927 let (mut decoder, _text, _events) = drive(&lines);
1928 assert_eq!(decoder.completed_tools.len(), 1);
1929
1930 decoder.finish();
1932 assert_eq!(
1933 decoder.completed_tools.len(),
1934 1,
1935 "finish() called twice must not double-emit"
1936 );
1937 }
1938
1939 #[test]
1940 fn finish_drains_active_tools_for_state_hygiene() {
1941 let lines = [
1946 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_z","name":"bash"}}"#,
1947 "",
1948 "data: [DONE]",
1949 "",
1950 ];
1951 let (decoder, _text, _events) = drive(&lines);
1952 assert_eq!(decoder.completed_tools.len(), 1);
1953 assert!(
1954 decoder.active_tools.is_empty(),
1955 "active_tools must be drained after finish()"
1956 );
1957 }
1958
1959 #[test]
1960 fn unknown_event_types_are_ignored() {
1961 let lines = [
1962 r#"data: {"type":"response.future_unknown_event","payload":{"x":1}}"#,
1963 "",
1964 r#"data: {"type":"response.output_text.delta","delta":"hi"}"#,
1965 "",
1966 ];
1967 let (_decoder, text_acc, _events) = drive(&lines);
1968 assert_eq!(text_acc, "hi");
1969 }
1970}
1971
1972#[cfg(test)]
1973mod broker_stream_tests {
1974 use super::*;
1980 use crate::auth::{CredentialBroker, LocalBroker};
1981 use std::sync::Arc;
1982
1983 async fn spawn_fake_openai_sse() -> (String, Arc<std::sync::Mutex<String>>) {
1984 use axum::routing::post;
1985 let seen_auth = Arc::new(std::sync::Mutex::new(String::new()));
1986 let seen = seen_auth.clone();
1987 let app = axum::Router::new().route(
1988 "/chat/completions",
1989 post(move |headers: axum::http::HeaderMap| {
1990 let seen = seen.clone();
1991 async move {
1992 *seen.lock().unwrap() = headers
1993 .get("authorization")
1994 .and_then(|v| v.to_str().ok())
1995 .unwrap_or("")
1996 .to_string();
1997 let body = concat!(
1998 "data: {\"choices\":[{\"delta\":{\"content\":\"Hel\"}}]}\n\n",
1999 "data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}\n\n",
2000 "data: [DONE]\n\n",
2001 );
2002 ([("content-type", "text/event-stream")], body)
2003 }
2004 }),
2005 );
2006 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2007 let addr = listener.local_addr().unwrap();
2008 tokio::spawn(async move {
2009 axum::serve(listener, app).await.unwrap();
2010 });
2011 (format!("http://{addr}"), seen_auth)
2012 }
2013
2014 #[tokio::test]
2015 async fn oai_stream_flows_through_broker_and_forwards_deltas() {
2016 let (upstream, seen_auth) = spawn_fake_openai_sse().await;
2017 let broker: Arc<dyn CredentialBroker> = Arc::new(LocalBroker::with_local_base_url(
2018 reqwest::Client::new(),
2019 upstream,
2020 ));
2021 let cfg = ProviderConfig {
2022 base_url: "unused-broker-derives-the-url".to_string(),
2023 model: "test-model".to_string(),
2024 provider: "local".to_string(),
2025 };
2026 let (tx, mut rx) = mpsc::unbounded_channel::<StreamEvent>();
2027 let cancel = tokio_util::sync::CancellationToken::new();
2028
2029 let result = call_oai_stream_inner(
2030 &cfg,
2031 &broker,
2032 &[],
2033 &None,
2034 &[],
2035 &tx,
2036 None,
2037 None,
2038 0,
2039 agent_core::reasoning::ReasoningLevel::Medium,
2040 &cancel,
2041 &crate::runtime::trace::TraceContext::disabled(),
2042 true,
2043 false,
2044 )
2045 .await
2046 .expect("stream must complete");
2047
2048 assert_eq!(result["content"][0]["text"], "Hello");
2050
2051 drop(tx);
2053 let mut streamed = String::new();
2054 while let Ok(ev) = rx.try_recv() {
2055 if let StreamEvent::Llm(crate::runtime::types::LlmEvent::Text(t)) = ev {
2056 streamed.push_str(&t);
2057 }
2058 }
2059 assert_eq!(streamed, "Hello");
2060
2061 assert_eq!(&*seen_auth.lock().unwrap(), "Bearer local");
2063 }
2064
2065 #[tokio::test]
2068 async fn suppressed_sync_route_does_not_enqueue_display_deltas() {
2069 let (upstream, _seen_auth) = spawn_fake_openai_sse().await;
2070 let broker: Arc<dyn CredentialBroker> = Arc::new(LocalBroker::with_local_base_url(
2071 reqwest::Client::new(),
2072 upstream,
2073 ));
2074 let cfg = ProviderConfig {
2075 base_url: "unused-broker-derives-the-url".to_string(),
2076 model: "test-model".to_string(),
2077 provider: "local".to_string(),
2078 };
2079 let (tx, mut rx) = mpsc::unbounded_channel::<StreamEvent>();
2080 let result = call_oai_stream_inner(
2081 &cfg,
2082 &broker,
2083 &[],
2084 &None,
2085 &[],
2086 &tx,
2087 None,
2088 None,
2089 0,
2090 agent_core::reasoning::ReasoningLevel::Medium,
2091 &tokio_util::sync::CancellationToken::new(),
2092 &crate::runtime::trace::TraceContext::disabled(),
2093 true,
2094 true,
2095 )
2096 .await
2097 .expect("stream must complete");
2098 assert_eq!(result["content"][0]["text"], "Hello");
2099 assert!(
2100 rx.try_recv().is_err(),
2101 "sync route must suppress display deltas at production"
2102 );
2103 }
2104
2105 #[tokio::test]
2106 async fn oai_stream_broker_error_fails_closed() {
2107 let broker: Arc<dyn CredentialBroker> = Arc::new(LocalBroker::new(reqwest::Client::new()));
2109 let cfg = ProviderConfig {
2110 base_url: String::new(),
2111 model: "m".to_string(),
2112 provider: "definitely-not-a-provider".to_string(),
2113 };
2114 let (tx, _rx) = mpsc::unbounded_channel::<StreamEvent>();
2115 let cancel = tokio_util::sync::CancellationToken::new();
2116 let err = call_oai_stream_inner(
2117 &cfg,
2118 &broker,
2119 &[],
2120 &None,
2121 &[],
2122 &tx,
2123 None,
2124 None,
2125 0,
2126 agent_core::reasoning::ReasoningLevel::Medium,
2127 &cancel,
2128 &crate::runtime::trace::TraceContext::disabled(),
2129 true,
2130 false,
2131 )
2132 .await
2133 .unwrap_err()
2134 .to_string();
2135 assert!(err.contains("unknown provider"), "got: {err}");
2136 assert!(!err.to_lowercase().contains("bearer"));
2137 }
2138
2139 #[tokio::test]
2144 async fn oai_stream_upstream_error_body_never_surfaces() {
2145 use axum::response::IntoResponse;
2146 const SENTINEL: &str = "PH1-OAI-BROKER-SENTINEL-91d2-RAW";
2147 let app = axum::Router::new().route(
2148 "/chat/completions",
2149 axum::routing::post(move |body: String| async move {
2150 (
2151 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
2152 [("content-type", "application/json")],
2153 format!("{{\"error\":{{\"message\":\"ECHOED:{body}\"}}}}"),
2154 )
2155 .into_response()
2156 }),
2157 );
2158 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2159 let addr = listener.local_addr().unwrap();
2160 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
2161
2162 let broker: Arc<dyn CredentialBroker> = Arc::new(LocalBroker::with_local_base_url(
2163 reqwest::Client::new(),
2164 format!("http://{addr}"),
2165 ));
2166 let cfg = ProviderConfig {
2167 base_url: "unused-broker-derives-the-url".to_string(),
2168 model: "test-model".to_string(),
2169 provider: "local".to_string(),
2170 };
2171 let tools = vec![serde_json::json!({
2172 "name": "ph1_secret_tool_zz",
2173 "description": "internal tool schema",
2174 "input_schema": {"type": "object", "properties": {}}
2175 })];
2176 let msgs: Vec<crate::SharedMessage> = vec![Arc::new(
2177 serde_json::json!({"role": "user", "content": SENTINEL}),
2178 )];
2179 let (tx, _rx) = mpsc::unbounded_channel::<StreamEvent>();
2180 let cancel = tokio_util::sync::CancellationToken::new();
2181
2182 let err = call_oai_stream_inner(
2183 &cfg,
2184 &broker,
2185 &tools,
2186 &Some(format!("system secret {SENTINEL}")),
2187 &msgs,
2188 &tx,
2189 None,
2190 None,
2191 0,
2192 agent_core::reasoning::ReasoningLevel::Medium,
2193 &cancel,
2194 &crate::runtime::trace::TraceContext::disabled(),
2195 true,
2196 false,
2197 )
2198 .await
2199 .expect_err("500 must fail")
2200 .to_string();
2201
2202 assert!(err.contains("500"), "status must survive: {err}");
2203 for banned in ["ECHOED", SENTINEL, "ph1_secret_tool_zz", "system secret"] {
2204 assert!(
2205 !err.contains(banned),
2206 "provider body content `{banned}` leaked into the surfaced error: {err}"
2207 );
2208 }
2209 }
2210}
2211
2212#[allow(clippy::too_many_arguments)]
2213pub(crate) async fn call_xai_responses_stream_inner(
2214 cfg: &ProviderConfig,
2215 broker: &std::sync::Arc<dyn crate::auth::CredentialBroker>,
2216 tools_schema: &[Value],
2217 system_prompt: &Option<String>,
2218 messages: &[crate::SharedMessage],
2219 tx: &mpsc::UnboundedSender<StreamEvent>,
2220 max_tokens: Option<u32>,
2221 reasoning_level: agent_core::reasoning::ReasoningLevel,
2222 cancel: &tokio_util::sync::CancellationToken,
2223 trace: &crate::runtime::trace::TraceContext,
2224 exact_wire_bytes: bool,
2225) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
2226 let (oai_tools, names) = translate::tools_to_oai(tools_schema);
2227 let input = codex_input_messages(translate::messages_to_oai(messages, system_prompt, &names));
2228 let tools: Vec<Value> = oai_tools
2229 .into_iter()
2230 .map(|tool| {
2231 json!({
2232 "type": "function",
2233 "name": tool.function.name,
2234 "description": tool.function.description.unwrap_or_default(),
2235 "parameters": tool.function.parameters,
2236 })
2237 })
2238 .collect();
2239 let body = build_xai_body(
2242 &cfg.model,
2243 reasoning_level,
2244 serde_json::to_value(input)?,
2245 tools,
2246 max_tokens,
2247 )
2248 .map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
2249 let (proxy_request, body_bytes) =
2255 crate::auth::ProxyRequest::post_json_exact("xai-auth", "/responses", body, true)?;
2256 let tracer = tr::begin_openai_tracer(
2257 trace,
2258 &cfg.provider,
2259 &cfg.model,
2260 if exact_wire_bytes {
2261 crate::runtime::trace::TransportKind::OpenAiResponses
2262 } else {
2263 crate::runtime::trace::TransportKind::CloudProxy
2264 },
2265 &format!("{}/responses", cfg.base_url.trim_end_matches('/')),
2266 exact_wire_bytes.then_some(body_bytes.as_ref()),
2267 messages,
2268 system_prompt.as_deref(),
2269 tools_schema,
2270 tr::renamed_tool_losses(&names),
2271 )
2272 .await;
2273 if let Some(tracer) = &tracer {
2279 trace.capture_request_content(tracer.request_id(), body_bytes.as_ref());
2280 }
2281 let mut attempt = tr::StreamAttempt::new(tracer);
2282 let stream = broker
2283 .proxy_stream(proxy_request)
2284 .await
2285 .map_err(|e| super::net::redact_provider_proxy_error(&e.to_string()));
2288 let mut stream = match stream {
2289 Ok(stream) => {
2290 attempt.mark_headers();
2291 stream
2292 }
2293 Err(msg) => {
2294 let status = tr::broker_error_status(&msg);
2295 let code = status.map_or_else(|| "broker_error".to_string(), |s| format!("http_{s}"));
2296 attempt.finish_failed(&code, status, None);
2297 return Err(msg.into());
2298 }
2299 };
2300 let mut text = String::new();
2301 let mut parser = CodexSseDecoder::default();
2302 let mut buf = bytes::BytesMut::new();
2303 while let Some(chunk) = tokio::select! {
2304 c = stream.next() => c,
2305 _ = cancel.cancelled() => {
2306 attempt.finish_canceled(None, parser.trace_usage());
2307 return Err("request canceled".into());
2308 }
2309 } {
2310 let chunk = match chunk {
2311 Ok(chunk) => {
2312 attempt.mark_first_byte();
2313 chunk
2314 }
2315 Err(e) => {
2316 attempt.finish_failed("stream_error", None, None);
2317 return Err(e.into());
2318 }
2319 };
2320 buf.extend_from_slice(&chunk);
2321 while let Some(n) = memchr::memchr(b'\n', &buf) {
2322 let line = buf.split_to(n + 1);
2323 parser.push_line(std::str::from_utf8(&line[..n]).unwrap_or(""), tx, &mut text);
2324 }
2325 if parser.saw_model_event {
2326 attempt.mark_first_model_event();
2327 }
2328 }
2329 if !buf.is_empty() {
2330 parser.push_line(std::str::from_utf8(&buf).unwrap_or(""), tx, &mut text);
2331 }
2332 parser.finish();
2333 if parser.saw_model_event {
2337 attempt.mark_first_model_event();
2338 }
2339 if let Err(failure) = parser.terminal_result() {
2343 attempt.finish_failed(failure.code, None, None);
2344 return Err(failure.message.into());
2345 }
2346 attempt.finish_success(None, None, None, parser.trace_usage());
2347 let mut content = Vec::new();
2348 if !text.is_empty() {
2349 content.push(json!({"type":"text","text":text}));
2350 }
2351 content.extend(translate::tool_calls_to_content_blocks(
2352 &parser.completed_tools,
2353 &names,
2354 ));
2355 Ok(json!({"role":"assistant","content":content}))
2356}
2357
2358pub(crate) fn build_xai_body(
2369 model: &str,
2370 level: agent_core::reasoning::ReasoningLevel,
2371 input: Value,
2372 tools: Vec<Value>,
2373 max_tokens: Option<u32>,
2374) -> Result<Value, String> {
2375 use agent_core::reasoning::ReasoningLevel;
2376 crate::runtime::openai::catalog::validation::validate_reasoning_mutation(
2377 &format!("xai-auth/{model}"),
2378 level,
2379 )?;
2380 let mut body = serde_json::Map::new();
2381 body.insert("model".into(), json!(model));
2382 body.insert("input".into(), input);
2383 body.insert("stream".into(), json!(true));
2384 if !tools.is_empty() {
2385 body.insert("tools".into(), Value::Array(tools));
2386 }
2387 if let Some(max) = max_tokens {
2388 body.insert("max_output_tokens".into(), json!(max));
2389 }
2390 match level {
2391 ReasoningLevel::Off | ReasoningLevel::Adaptive => {}
2394 l => {
2395 body.insert("reasoning".into(), json!({ "effort": l.as_str() }));
2396 }
2397 }
2398 Ok(Value::Object(body))
2399}
2400
2401#[cfg(test)]
2402mod xai_tests {
2403 use super::*;
2404 use agent_core::reasoning::ReasoningLevel;
2405
2406 #[test]
2407 fn xai_fixture_is_public_responses_shape() {
2408 let fixture = serde_json::json!({"type":"response.output_text.delta","delta":"hello"});
2409 assert_eq!(fixture["type"], "response.output_text.delta");
2410 assert_eq!(fixture["delta"], "hello");
2411 }
2412
2413 fn body_for(model: &str, level: ReasoningLevel) -> Result<Value, String> {
2414 build_xai_body(model, level, json!([]), Vec::new(), Some(1024))
2415 }
2416
2417 #[test]
2420 fn grok45_emits_exact_documented_efforts() {
2421 for (level, effort) in [
2422 (ReasoningLevel::Low, "low"),
2423 (ReasoningLevel::Medium, "medium"),
2424 (ReasoningLevel::High, "high"),
2425 ] {
2426 let body = body_for("grok-4.5", level).expect("supported effort");
2427 assert_eq!(body["reasoning"], json!({"effort": effort}), "{level}");
2428 assert_eq!(body["model"], "grok-4.5");
2429 assert_eq!(body["stream"], json!(true));
2430 assert_eq!(body["max_output_tokens"], json!(1024));
2431 }
2432 }
2433
2434 #[test]
2435 fn grok46_emits_exact_documented_efforts() {
2436 for (level, effort) in [
2437 (ReasoningLevel::Low, "low"),
2438 (ReasoningLevel::Medium, "medium"),
2439 (ReasoningLevel::High, "high"),
2440 ] {
2441 let body = body_for("grok-4.6", level).expect("supported effort");
2442 assert_eq!(body["reasoning"], json!({"effort": effort}), "{level}");
2443 assert_eq!(body["model"], "grok-4.6");
2444 assert_eq!(body["stream"], json!(true));
2445 assert_eq!(body["max_output_tokens"], json!(1024));
2446 }
2447 }
2448
2449 #[test]
2450 fn adaptive_omits_reasoning_field_provider_default() {
2451 for model in [
2452 "grok-4.5",
2453 "grok-4.5-latest",
2454 "grok-4.6",
2455 "grok-4.3",
2456 "grok-4.20-0309-non-reasoning",
2457 ] {
2458 let body = body_for(model, ReasoningLevel::Adaptive).expect("adaptive is omission");
2459 assert!(body.get("reasoning").is_none(), "{model}");
2460 }
2461 }
2462
2463 #[test]
2464 fn off_is_rejected_pre_network_when_reasoning_cannot_be_disabled() {
2465 for model in [
2466 "grok-4.5",
2467 "grok-4.5-latest",
2468 "grok-4.6",
2469 "grok-4.20-multi-agent-0309",
2470 "grok-4.3",
2471 ] {
2472 let err = body_for(model, ReasoningLevel::Off).unwrap_err();
2473 assert!(err.contains(model), "{model}: {err}");
2474 }
2475 let body = body_for("grok-4.20-0309-non-reasoning", ReasoningLevel::Off).unwrap();
2477 assert!(body.get("reasoning").is_none());
2478 }
2479
2480 #[test]
2481 fn unsupported_named_efforts_are_rejected_pre_network() {
2482 for level in [
2484 ReasoningLevel::XHigh,
2485 ReasoningLevel::Max,
2486 ReasoningLevel::Ultra,
2487 ] {
2488 assert!(body_for("grok-4.5", level).is_err(), "{level}");
2489 assert!(body_for("grok-4.6", level).is_err(), "{level}");
2490 }
2491 assert!(body_for("grok-4.3", ReasoningLevel::Medium).is_err());
2493 assert!(body_for("grok-4.20-0309-non-reasoning", ReasoningLevel::Medium).is_err());
2495 assert!(body_for("grok-9000", ReasoningLevel::Medium).is_err());
2497 }
2498
2499 #[test]
2500 fn multi_agent_xhigh_is_exact_agent_count_control() {
2501 let body = body_for("grok-4.20-multi-agent-0309", ReasoningLevel::XHigh).unwrap();
2502 assert_eq!(body["reasoning"], json!({"effort": "xhigh"}));
2503 }
2504
2505 #[test]
2506 fn responses_wire_shape_tools_flat_and_no_chat_completions_fields() {
2507 let tools = vec![json!({
2508 "type": "function",
2509 "name": "get_weather",
2510 "description": "d",
2511 "parameters": {"type": "object"}
2512 })];
2513 let body =
2514 build_xai_body("grok-4.5", ReasoningLevel::High, json!([]), tools, None).unwrap();
2515 assert_eq!(body["tools"][0]["name"], "get_weather");
2518 assert!(body["tools"][0].get("function").is_none());
2519 assert!(body.get("input").is_some());
2520 assert!(body.get("messages").is_none());
2521 assert!(body.get("max_output_tokens").is_none());
2522 }
2523}
2524
2525#[cfg(test)]
2528mod codex_wire_tests {
2529 use crate::runtime::openai::catalog::{
2530 plan_codex_execution, CodexMultiAgentMode, CodexRequestRole,
2531 };
2532 use agent_core::reasoning::ReasoningLevel;
2533
2534 fn plan(
2535 level: ReasoningLevel,
2536 role: CodexRequestRole,
2537 ) -> crate::runtime::openai::catalog::CodexExecutionPlan {
2538 plan_codex_execution("openai-codex/gpt-5.6-sol", level, role, None)
2539 .expect("Sol request plan")
2540 }
2541
2542 #[allow(clippy::too_many_arguments)]
2543 fn body_for_level(
2544 model: &str,
2545 level: ReasoningLevel,
2546 input: serde_json::Value,
2547 instructions: String,
2548 tools: Vec<serde_json::Value>,
2549 temperature: Option<f32>,
2550 max_tokens: Option<u32>,
2551 ) -> serde_json::Value {
2552 let qualified = format!("openai-codex/{model}");
2553 let plan = plan_codex_execution(&qualified, level, CodexRequestRole::Foreground, None)
2554 .expect("request plan");
2555 super::build_codex_body(
2556 model,
2557 &plan,
2558 input,
2559 instructions,
2560 tools,
2561 temperature,
2562 max_tokens,
2563 "synaps-test-cache-key",
2564 )
2565 }
2566
2567 fn codex_effort_for(model_id: &str, level: ReasoningLevel) -> Result<String, String> {
2569 let qualified = format!("openai-codex/{model_id}");
2570 let plan = plan_codex_execution(&qualified, level, CodexRequestRole::Foreground, None)
2571 .map_err(|error| error.to_string())?;
2572 plan.wire_effort
2573 .map(|effort| effort.as_str().to_string())
2574 .ok_or_else(|| "reasoning effort omitted".to_string())
2575 }
2576
2577 #[test]
2578 fn sol_lowers_ultra_to_max_for_requests() {
2579 let effort = codex_effort_for("gpt-5.6-sol", ReasoningLevel::Ultra).unwrap();
2580 assert_eq!(effort, "max");
2581 }
2582
2583 #[test]
2584 fn sol_emits_max_exact() {
2585 let effort = codex_effort_for("gpt-5.6-sol", ReasoningLevel::Max).unwrap();
2586 assert_eq!(effort, "max");
2587 }
2588
2589 #[test]
2590 fn sol_emits_xhigh_exact() {
2591 let effort = codex_effort_for("gpt-5.6-sol", ReasoningLevel::XHigh).unwrap();
2592 assert_eq!(effort, "xhigh");
2593 }
2594
2595 #[test]
2596 fn luna_emits_max_exact() {
2597 let effort = codex_effort_for("gpt-5.6-luna", ReasoningLevel::Max).unwrap();
2598 assert_eq!(effort, "max");
2599 }
2600
2601 #[test]
2602 fn luna_rejects_ultra_before_network() {
2603 let err = codex_effort_for("gpt-5.6-luna", ReasoningLevel::Ultra).unwrap_err();
2605 assert!(err.contains("ultra"), "{err}");
2606 assert!(err.contains("gpt-5.6-luna"), "{err}");
2607 }
2608
2609 #[test]
2610 fn gpt55_emits_xhigh_exact() {
2611 let effort = codex_effort_for("gpt-5.5", ReasoningLevel::XHigh).unwrap();
2612 assert_eq!(effort, "xhigh");
2613 }
2614
2615 #[test]
2616 fn gpt55_rejects_max_before_network() {
2617 assert!(codex_effort_for("gpt-5.5", ReasoningLevel::Max).is_err());
2618 assert!(codex_effort_for("gpt-5.5", ReasoningLevel::Ultra).is_err());
2619 }
2620
2621 #[test]
2622 fn off_and_adaptive_omit_reasoning_field() {
2623 for level in [ReasoningLevel::Off, ReasoningLevel::Adaptive] {
2625 let body = body_for_level(
2626 "gpt-5.6-sol",
2627 level,
2628 serde_json::json!([]),
2629 "sys".to_string(),
2630 vec![],
2631 None,
2632 None,
2633 );
2634 assert!(
2635 body.get("reasoning").is_none(),
2636 "{level:?} must not emit reasoning field, body={body}"
2637 );
2638 }
2639 }
2640
2641 #[test]
2642 fn max_as_str_is_not_xhigh() {
2643 assert_ne!(ReasoningLevel::Max.as_str(), ReasoningLevel::XHigh.as_str());
2644 assert_eq!(ReasoningLevel::Max.as_str(), "max");
2645 }
2646
2647 #[test]
2648 fn ultra_mode_context_is_exact_proactive_developer_item() {
2649 let plan = plan(ReasoningLevel::Ultra, CodexRequestRole::Foreground);
2650 assert_eq!(plan.multi_agent_mode, Some(CodexMultiAgentMode::Proactive));
2651 let item = super::codex_multi_agent_mode_item(&plan).expect("Ultra context item");
2652 assert_eq!(
2653 item.get("type").and_then(serde_json::Value::as_str),
2654 Some("message")
2655 );
2656 assert_eq!(
2657 item.get("role").and_then(serde_json::Value::as_str),
2658 Some("developer")
2659 );
2660 assert_eq!(
2661 item.pointer("/content/0/text")
2662 .and_then(serde_json::Value::as_str),
2663 Some(concat!(
2664 "<multi_agent_mode>",
2665 "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.",
2666 "</multi_agent_mode>"
2667 ))
2668 );
2669 }
2670
2671 #[test]
2672 fn max_and_xhigh_mode_context_is_exact_explicit_only_item() {
2673 for level in [ReasoningLevel::Max, ReasoningLevel::XHigh] {
2674 let plan = plan(level, CodexRequestRole::Foreground);
2675 assert_eq!(
2676 plan.multi_agent_mode,
2677 Some(CodexMultiAgentMode::ExplicitRequestOnly)
2678 );
2679 let item = super::codex_multi_agent_mode_item(&plan).expect("v2 context item");
2680 assert_eq!(
2681 item.pointer("/content/0/text")
2682 .and_then(serde_json::Value::as_str),
2683 Some(concat!(
2684 "<multi_agent_mode>",
2685 "Do not spawn sub-agents unless the user or applicable AGENTS.md/skill instructions explicitly ask for sub-agents, delegation, or parallel agent work.",
2686 "</multi_agent_mode>"
2687 )),
2688 "{level:?}"
2689 );
2690 }
2691 }
2692
2693 #[test]
2694 fn mode_context_is_inserted_once_at_stable_head_position() {
2695 let plan = plan(ReasoningLevel::Ultra, CodexRequestRole::Foreground);
2696 let mut input = vec![
2697 serde_json::json!({"role":"user","content":"first"}),
2698 serde_json::json!({"role":"assistant","content":"answer"}),
2699 serde_json::json!({"role":"user","content":"current"}),
2700 ];
2701 super::insert_codex_multi_agent_mode(&mut input, &plan);
2702 let mode_positions = input
2703 .iter()
2704 .enumerate()
2705 .filter(|(_, item)| {
2706 item.pointer("/content/0/text")
2707 .and_then(serde_json::Value::as_str)
2708 .is_some_and(|text| text.starts_with("<multi_agent_mode>"))
2709 })
2710 .map(|(index, _)| index)
2711 .collect::<Vec<_>>();
2712 assert_eq!(mode_positions, vec![0]);
2713 assert_eq!(
2714 input[3].get("content").and_then(serde_json::Value::as_str),
2715 Some("current")
2716 );
2717 }
2718
2719 #[test]
2725 fn mode_context_placement_preserves_prompt_prefix_across_turns() {
2726 let plan = plan(ReasoningLevel::Ultra, CodexRequestRole::Foreground);
2727 let turn1 = vec![
2728 serde_json::json!({"role":"user","content":"first"}),
2729 serde_json::json!({"role":"assistant","content":"answer"}),
2730 ];
2731 let mut turn2 = turn1.clone();
2732 turn2.push(serde_json::json!({"role":"user","content":"follow-up"}));
2733 let mut input1 = turn1;
2734 let mut input2 = turn2;
2735 super::insert_codex_multi_agent_mode(&mut input1, &plan);
2736 super::insert_codex_multi_agent_mode(&mut input2, &plan);
2737 assert_eq!(
2738 &input2[..input1.len()],
2739 &input1[..],
2740 "turn N's input must be a strict prefix of turn N+1's input"
2741 );
2742 }
2743
2744 #[test]
2745 fn prompt_cache_key_is_stable_for_the_same_conversation_head() {
2746 let first = serde_json::json!({"role":"user","content":"hello"});
2747 let a = super::codex_prompt_cache_key("sys", Some(&first));
2748 let b = super::codex_prompt_cache_key("sys", Some(&first));
2749 assert_eq!(a, b);
2750 assert!(a.starts_with("synaps-"), "{a}");
2751 }
2752
2753 #[test]
2754 fn prompt_cache_key_differs_across_conversations() {
2755 let first = serde_json::json!({"role":"user","content":"hello"});
2756 let other = serde_json::json!({"role":"user","content":"different task"});
2757 assert_ne!(
2758 super::codex_prompt_cache_key("sys", Some(&first)),
2759 super::codex_prompt_cache_key("sys", Some(&other)),
2760 );
2761 assert_ne!(
2762 super::codex_prompt_cache_key("sys", Some(&first)),
2763 super::codex_prompt_cache_key("other-sys", Some(&first)),
2764 );
2765 }
2766
2767 #[test]
2768 fn body_carries_prompt_cache_key() {
2769 let body = body_for_level(
2770 "gpt-5.6-sol",
2771 ReasoningLevel::Medium,
2772 serde_json::json!([]),
2773 "sys".to_string(),
2774 vec![],
2775 None,
2776 None,
2777 );
2778 assert_eq!(
2779 body.get("prompt_cache_key")
2780 .and_then(serde_json::Value::as_str),
2781 Some("synaps-test-cache-key")
2782 );
2783 }
2784
2785 #[test]
2786 fn worker_ultra_has_no_mode_context_item() {
2787 let plan = plan(ReasoningLevel::Ultra, CodexRequestRole::Worker);
2788 assert!(super::codex_multi_agent_mode_item(&plan).is_none());
2789 }
2790
2791 #[test]
2794 fn body_lowers_ultra_reasoning_effort_to_max() {
2795 let body = body_for_level(
2796 "gpt-5.6-sol",
2797 ReasoningLevel::Ultra,
2798 serde_json::json!([]),
2799 "sys".to_string(),
2800 vec![],
2801 None,
2802 None,
2803 );
2804 let effort = body
2805 .get("reasoning")
2806 .and_then(|r| r.get("effort"))
2807 .and_then(serde_json::Value::as_str)
2808 .expect("reasoning.effort must be present for Ultra");
2809 assert_eq!(effort, "max");
2810 }
2811
2812 #[test]
2813 fn body_contains_reasoning_effort_for_max() {
2814 let body = body_for_level(
2815 "gpt-5.6-sol",
2816 ReasoningLevel::Max,
2817 serde_json::json!([]),
2818 "sys".to_string(),
2819 vec![],
2820 None,
2821 None,
2822 );
2823 let effort = body
2824 .get("reasoning")
2825 .and_then(|r| r.get("effort"))
2826 .and_then(serde_json::Value::as_str)
2827 .expect("reasoning.effort must be present for Max");
2828 assert_eq!(effort, "max");
2829 assert_ne!(effort, "xhigh");
2831 }
2832
2833 #[test]
2834 fn body_contains_reasoning_effort_for_xhigh() {
2835 let body = body_for_level(
2836 "gpt-5.6-sol",
2837 ReasoningLevel::XHigh,
2838 serde_json::json!([]),
2839 "sys".to_string(),
2840 vec![],
2841 None,
2842 None,
2843 );
2844 let effort = body
2845 .get("reasoning")
2846 .and_then(|r| r.get("effort"))
2847 .and_then(serde_json::Value::as_str)
2848 .expect("reasoning.effort must be present for XHigh");
2849 assert_eq!(effort, "xhigh");
2850 }
2851
2852 #[test]
2853 fn body_contains_reasoning_effort_for_low_medium_high() {
2854 for (level, expected) in [
2855 (ReasoningLevel::Low, "low"),
2856 (ReasoningLevel::Medium, "medium"),
2857 (ReasoningLevel::High, "high"),
2858 ] {
2859 let body = body_for_level(
2860 "gpt-5.6-sol",
2861 level,
2862 serde_json::json!([]),
2863 "sys".to_string(),
2864 vec![],
2865 None,
2866 None,
2867 );
2868 let effort = body
2869 .get("reasoning")
2870 .and_then(|r| r.get("effort"))
2871 .and_then(serde_json::Value::as_str)
2872 .unwrap_or_else(|| panic!("{level:?} must emit reasoning.effort"));
2873 assert_eq!(effort, expected, "{level:?}");
2874 }
2875 }
2876
2877 #[test]
2878 fn body_always_sets_model_and_stream_and_include() {
2879 let body = body_for_level(
2880 "gpt-5.5",
2881 ReasoningLevel::XHigh,
2882 serde_json::json!([]),
2883 "instructions".to_string(),
2884 vec![],
2885 None,
2886 None,
2887 );
2888 assert_eq!(
2889 body.get("model").and_then(serde_json::Value::as_str),
2890 Some("gpt-5.5")
2891 );
2892 assert_eq!(
2893 body.get("stream").and_then(serde_json::Value::as_bool),
2894 Some(true)
2895 );
2896 let includes = body.get("include").expect("include must be present");
2897 assert!(
2898 includes.as_array().map_or(false, |a| {
2899 a.iter()
2900 .any(|v| v.as_str() == Some("reasoning.encrypted_content"))
2901 }),
2902 "include must contain reasoning.encrypted_content"
2903 );
2904 assert_eq!(
2905 body.get("store").and_then(serde_json::Value::as_bool),
2906 Some(false)
2907 );
2908 }
2909
2910 #[test]
2911 fn body_omits_temperature_and_max_tokens_when_none() {
2912 let body = body_for_level(
2913 "gpt-5.6-sol",
2914 ReasoningLevel::Medium,
2915 serde_json::json!([]),
2916 "sys".to_string(),
2917 vec![],
2918 None,
2919 None,
2920 );
2921 assert!(body.get("temperature").is_none());
2922 assert!(body.get("max_output_tokens").is_none());
2923 }
2924
2925 #[test]
2926 fn body_includes_temperature_and_max_tokens_when_some() {
2927 let body = body_for_level(
2928 "gpt-5.6-sol",
2929 ReasoningLevel::High,
2930 serde_json::json!([]),
2931 "sys".to_string(),
2932 vec![],
2933 Some(0.7),
2934 Some(2048),
2935 );
2936 let temp = body
2937 .get("temperature")
2938 .and_then(serde_json::Value::as_f64)
2939 .unwrap();
2940 assert!((temp - 0.7f64).abs() < 0.01, "temperature={temp}");
2941 assert_eq!(
2942 body.get("max_output_tokens")
2943 .and_then(serde_json::Value::as_u64),
2944 Some(2048)
2945 );
2946 }
2947}
2948
2949#[cfg(test)]
2950mod send_retry_tests {
2951 use super::*;
2961 use agent_core::auth::{
2962 AccessToken, BrokerError, ProxyByteStream, ProxyRequest, ProxyResponse,
2963 };
2964 use agent_core::reasoning::ReasoningLevel;
2965 use async_trait::async_trait;
2966 use axum::{http::StatusCode, response::IntoResponse, routing::post as axum_post, Router};
2967 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
2968 use std::sync::{
2969 atomic::{AtomicUsize, Ordering},
2970 Arc,
2971 };
2972 use tokio::sync::mpsc;
2973
2974 const CODEX_SSE_SUCCESS: &str = concat!(
2976 "data: {\"type\":\"response.output_text.delta\",\"delta\":\"hello\"}\n\n",
2977 "data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n",
2978 "data: [DONE]\n\n",
2979 );
2980
2981 fn fake_codex_token() -> String {
2985 let payload = serde_json::json!({
2986 "https://api.openai.com/auth": { "chatgpt_account_id": "acct_test" }
2987 });
2988 format!("h.{}.s", URL_SAFE_NO_PAD.encode(payload.to_string()))
2989 }
2990
2991 struct TokenOnlyBroker;
2994
2995 #[async_trait]
2996 impl crate::auth::CredentialBroker for TokenOnlyBroker {
2997 async fn access_token(
2998 &self,
2999 _p: agent_core::auth::OAuthProviderId,
3000 ) -> Result<AccessToken, BrokerError> {
3001 Ok(AccessToken {
3002 token: fake_codex_token(),
3003 expires: u64::MAX,
3004 })
3005 }
3006 async fn proxy(&self, _request: ProxyRequest) -> Result<ProxyResponse, BrokerError> {
3007 Err(BrokerError::Denied("not implemented in stub".into()))
3008 }
3009 async fn proxy_stream(
3010 &self,
3011 _request: ProxyRequest,
3012 ) -> Result<ProxyByteStream, BrokerError> {
3013 Err(BrokerError::Denied("not implemented in stub".into()))
3014 }
3015 async fn anthropic_usage(&self) -> Result<serde_json::Value, BrokerError> {
3016 Err(BrokerError::Denied("not implemented in stub".into()))
3017 }
3018 async fn capabilities(&self) -> Result<Vec<agent_core::auth::ProviderStatus>, BrokerError> {
3019 Ok(vec![])
3020 }
3021 }
3022
3023 struct DenyingCountingBroker {
3024 access_calls: Arc<AtomicUsize>,
3025 }
3026
3027 #[async_trait]
3028 impl crate::auth::CredentialBroker for DenyingCountingBroker {
3029 async fn access_token(
3030 &self,
3031 _provider: agent_core::auth::OAuthProviderId,
3032 ) -> Result<AccessToken, BrokerError> {
3033 self.access_calls.fetch_add(1, Ordering::SeqCst);
3034 Err(BrokerError::Denied(
3035 "credential access must not occur".into(),
3036 ))
3037 }
3038
3039 async fn proxy(&self, _request: ProxyRequest) -> Result<ProxyResponse, BrokerError> {
3040 Err(BrokerError::Denied("not implemented in stub".into()))
3041 }
3042
3043 async fn proxy_stream(
3044 &self,
3045 _request: ProxyRequest,
3046 ) -> Result<ProxyByteStream, BrokerError> {
3047 Err(BrokerError::Denied("not implemented in stub".into()))
3048 }
3049
3050 async fn anthropic_usage(&self) -> Result<serde_json::Value, BrokerError> {
3051 Err(BrokerError::Denied("not implemented in stub".into()))
3052 }
3053
3054 async fn capabilities(&self) -> Result<Vec<agent_core::auth::ProviderStatus>, BrokerError> {
3055 Ok(vec![])
3056 }
3057 }
3058
3059 async fn spawn_mock_codex(
3062 fail_count: usize,
3063 fail_status: StatusCode,
3064 ) -> (String, Arc<AtomicUsize>) {
3065 let counter = Arc::new(AtomicUsize::new(0));
3066 let counter_clone = Arc::clone(&counter);
3067 let app = Router::new().route(
3068 "/codex/responses",
3069 axum_post(move || {
3070 let counter = Arc::clone(&counter_clone);
3071 async move {
3072 let n = counter.fetch_add(1, Ordering::SeqCst);
3073 if n < fail_count {
3074 (
3075 fail_status,
3076 [("content-type", "application/json")],
3077 "{\"error\":{\"message\":\"transient upstream sadness\"}}".to_string(),
3078 )
3079 .into_response()
3080 } else {
3081 (
3082 StatusCode::OK,
3083 [("content-type", "text/event-stream")],
3084 CODEX_SSE_SUCCESS.to_string(),
3085 )
3086 .into_response()
3087 }
3088 }
3089 }),
3090 );
3091 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3092 let addr = listener.local_addr().unwrap();
3093 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
3094 (format!("http://{addr}"), counter)
3095 }
3096
3097 async fn spawn_codex_sse(body: &'static str) -> String {
3098 let app = Router::new().route(
3099 "/codex/responses",
3100 axum_post(move || async move {
3101 (
3102 StatusCode::OK,
3103 [("content-type", "text/event-stream")],
3104 body,
3105 )
3106 }),
3107 );
3108 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3109 let addr = listener.local_addr().unwrap();
3110 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
3111 format!("http://{addr}")
3112 }
3113
3114 async fn spawn_empty_then_success_codex() -> (
3115 String,
3116 Arc<AtomicUsize>,
3117 Arc<std::sync::Mutex<Vec<bytes::Bytes>>>,
3118 ) {
3119 let counter = Arc::new(AtomicUsize::new(0));
3120 let bodies = Arc::new(std::sync::Mutex::new(Vec::new()));
3121 let app = Router::new().route(
3122 "/codex/responses",
3123 axum_post({
3124 let counter = Arc::clone(&counter);
3125 let bodies = Arc::clone(&bodies);
3126 move |body: bytes::Bytes| {
3127 let counter = Arc::clone(&counter);
3128 let bodies = Arc::clone(&bodies);
3129 async move {
3130 bodies.lock().unwrap().push(body);
3131 let n = counter.fetch_add(1, Ordering::SeqCst);
3132 let response = if n == 0 {
3133 concat!(
3134 "data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":7,\"output_tokens\":0}}}\n\n",
3135 "data: [DONE]\n\n",
3136 )
3137 } else {
3138 CODEX_SSE_SUCCESS
3139 };
3140 (
3141 StatusCode::OK,
3142 [("content-type", "text/event-stream")],
3143 response,
3144 )
3145 }
3146 }
3147 }),
3148 );
3149 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3150 let addr = listener.local_addr().unwrap();
3151 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
3152 (format!("http://{addr}"), counter, bodies)
3153 }
3154
3155 async fn run_codex(
3156 base_url: &str,
3157 max_retries: u32,
3158 ) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
3159 let cfg = ProviderConfig {
3160 base_url: base_url.to_string(),
3161 model: "gpt-5.6-sol".to_string(),
3163 provider: "openai-codex".to_string(),
3164 };
3165 let client = reqwest::Client::new();
3166 let broker: std::sync::Arc<dyn crate::auth::CredentialBroker> =
3167 std::sync::Arc::new(TokenOnlyBroker);
3168 let (tx, _rx) = mpsc::unbounded_channel();
3169 call_codex_stream_inner(
3170 &cfg,
3171 &client,
3172 &broker,
3173 &[],
3174 &Some("test".to_string()),
3175 &[],
3176 &tx,
3177 None,
3178 None,
3179 agent_core::reasoning::ReasoningLevel::Medium,
3180 crate::runtime::openai::catalog::CodexRequestRole::Foreground,
3181 &tokio_util::sync::CancellationToken::new(),
3182 max_retries,
3183 &crate::runtime::trace::TraceContext::disabled(),
3184 )
3185 .await
3186 }
3187
3188 #[tokio::test]
3189 async fn direct_ultra_tool_guard_precedes_broker_access() {
3190 let access_calls = Arc::new(AtomicUsize::new(0));
3191 let broker: Arc<dyn crate::auth::CredentialBroker> = Arc::new(DenyingCountingBroker {
3192 access_calls: Arc::clone(&access_calls),
3193 });
3194 let cfg = ProviderConfig {
3195 base_url: "http://127.0.0.1:1".to_string(),
3196 model: "gpt-5.6-sol".to_string(),
3197 provider: "openai-codex".to_string(),
3198 };
3199 let (tx, _rx) = mpsc::unbounded_channel();
3200
3201 let error = call_codex_stream_inner(
3202 &cfg,
3203 &reqwest::Client::new(),
3204 &broker,
3205 &[],
3206 &Some("test".to_string()),
3207 &[],
3208 &tx,
3209 None,
3210 None,
3211 ReasoningLevel::Ultra,
3212 crate::runtime::openai::catalog::CodexRequestRole::Foreground,
3213 &tokio_util::sync::CancellationToken::new(),
3214 0,
3215 &crate::runtime::trace::TraceContext::disabled(),
3216 )
3217 .await
3218 .expect_err("foreground Ultra without delegation tools must fail closed");
3219
3220 assert!(error.to_string().contains("subagent_start"), "{error}");
3221 assert_eq!(
3222 access_calls.load(Ordering::SeqCst),
3223 0,
3224 "preflight denial must happen before broker credential access"
3225 );
3226 }
3227
3228 #[tokio::test]
3229 async fn direct_codex_path_rejects_provider_identity_before_broker_access() {
3230 let access_calls = Arc::new(AtomicUsize::new(0));
3231 let broker: Arc<dyn crate::auth::CredentialBroker> = Arc::new(DenyingCountingBroker {
3232 access_calls: Arc::clone(&access_calls),
3233 });
3234 let cfg = ProviderConfig {
3235 base_url: "http://127.0.0.1:1".to_string(),
3236 model: "gpt-5.6-sol".to_string(),
3237 provider: "openrouter".to_string(),
3238 };
3239 let (tx, _rx) = mpsc::unbounded_channel();
3240
3241 let error = call_codex_stream_inner(
3242 &cfg,
3243 &reqwest::Client::new(),
3244 &broker,
3245 &[],
3246 &Some("test".to_string()),
3247 &[],
3248 &tx,
3249 None,
3250 None,
3251 ReasoningLevel::Medium,
3252 crate::runtime::openai::catalog::CodexRequestRole::Foreground,
3253 &tokio_util::sync::CancellationToken::new(),
3254 0,
3255 &crate::runtime::trace::TraceContext::disabled(),
3256 )
3257 .await
3258 .expect_err("the Codex seam must validate provider-qualified identity");
3259
3260 assert!(error.to_string().contains("openrouter"), "{error}");
3261 assert_eq!(
3262 access_calls.load(Ordering::SeqCst),
3263 0,
3264 "provider identity denial must precede broker credential access"
3265 );
3266 }
3267
3268 #[tokio::test]
3274 async fn codex_response_failed_event_is_a_terminal_error_not_empty_success() {
3275 let base_url = spawn_codex_sse(concat!(
3276 "data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"type\":\"server_error\",\"message\":\"ECHOED:secret prompt\"}}}\n\n",
3277 "data: [DONE]\n\n",
3278 ))
3279 .await;
3280 let err = run_codex(&base_url, 0)
3281 .await
3282 .expect_err("response.failed must fail the request");
3283 let msg = err.to_string();
3284 assert!(
3285 msg.starts_with("Codex response failed in stream."),
3286 "static failure message expected: {msg}"
3287 );
3288 assert!(
3289 !msg.contains("ECHOED") && !msg.contains("secret prompt"),
3290 "provider error body must never surface: {msg}"
3291 );
3292 }
3293
3294 #[tokio::test]
3297 async fn codex_response_incomplete_event_is_a_terminal_error() {
3298 let base_url = spawn_codex_sse(concat!(
3299 "data: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\n\n",
3300 "data: {\"type\":\"response.incomplete\",\"response\":{\"incomplete_details\":{\"reason\":\"max_output_tokens\"}}}\n\n",
3301 "data: [DONE]\n\n",
3302 ))
3303 .await;
3304 let err = run_codex(&base_url, 0)
3305 .await
3306 .expect_err("response.incomplete must fail the request");
3307 assert!(
3308 err.to_string()
3309 .starts_with("Codex response was incomplete."),
3310 "got: {err}"
3311 );
3312 }
3313
3314 #[tokio::test]
3317 async fn codex_eof_without_terminal_event_fails_closed() {
3318 let base_url = spawn_codex_sse(
3319 "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\"}}\n\n",
3320 )
3321 .await;
3322 let err = run_codex(&base_url, 0)
3323 .await
3324 .expect_err("EOF without terminal event must fail");
3325 assert!(
3326 err.to_string()
3327 .starts_with("Codex response stream ended without a terminal event."),
3328 "got: {err}"
3329 );
3330 }
3331
3332 #[tokio::test]
3333 async fn codex_completed_without_output_retries_identical_bytes_then_succeeds() {
3334 let (base_url, counter, bodies) = spawn_empty_then_success_codex().await;
3335 let result = run_codex(&base_url, 1)
3336 .await
3337 .expect("one empty-success stream with retries available must not abort the turn");
3338
3339 assert_eq!(counter.load(Ordering::SeqCst), 2, "expected one retry");
3340 let received = bodies.lock().unwrap();
3341 assert_eq!(received.len(), 2, "both attempts must reach the endpoint");
3342 assert_eq!(
3343 received[0], received[1],
3344 "the logical request must resend byte-identical content"
3345 );
3346 assert_eq!(result["content"][0]["text"], "hello");
3347 }
3348
3349 #[tokio::test]
3350 async fn codex_retries_transient_500_then_succeeds() {
3351 let (base_url, counter) = spawn_mock_codex(1, StatusCode::INTERNAL_SERVER_ERROR).await;
3352 let result = run_codex(&base_url, 2)
3353 .await
3354 .expect("one transient 500 with retries available must not abort the turn");
3355 assert_eq!(
3356 counter.load(Ordering::SeqCst),
3357 2,
3358 "expected exactly one retry"
3359 );
3360 let text = result["content"][0]["text"].as_str().unwrap_or_default();
3361 assert_eq!(text, "hello");
3362 }
3363
3364 #[tokio::test]
3365 async fn codex_response_failed_surfaces_typed_provider_error_not_empty_response() {
3366 const BODY: &str = concat!(
3367 "data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"type\":\"server_error\",\"message\":\"ECHOED:private prompt\"}}}\n\n",
3368 "data: [DONE]\n\n",
3369 );
3370 let base_url = spawn_codex_sse(BODY).await;
3371 let err = run_codex(&base_url, 0).await.expect_err("must fail");
3372 let msg = err.to_string();
3373 assert!(msg.starts_with("Codex response failed in stream."), "{msg}");
3374 assert!(!msg.contains("empty response"), "{msg}");
3375 assert!(!msg.contains("ECHOED"), "provider text leaked: {msg}");
3376 assert!(
3377 !msg.contains("private prompt"),
3378 "provider text leaked: {msg}"
3379 );
3380 }
3381
3382 #[tokio::test]
3383 async fn codex_response_incomplete_surfaces_typed_provider_error_not_empty_response() {
3384 const BODY: &str = concat!(
3385 "data: {\"type\":\"response.incomplete\",\"response\":{\"incomplete_details\":{\"reason\":\"max_output_tokens\"}}}\n\n",
3386 "data: [DONE]\n\n",
3387 );
3388 let base_url = spawn_codex_sse(BODY).await;
3389 let err = run_codex(&base_url, 0).await.expect_err("must fail");
3390 let msg = err.to_string();
3391 assert!(msg.starts_with("Codex response was incomplete."), "{msg}");
3392 assert!(!msg.contains("empty response"), "{msg}");
3393 assert!(
3394 !msg.contains("max_output_tokens"),
3395 "provider text leaked: {msg}"
3396 );
3397 }
3398
3399 #[tokio::test]
3400 async fn codex_clean_eof_without_terminal_fails_closed() {
3401 let base_url = spawn_codex_sse(
3402 "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_123\"}}\n\n",
3403 )
3404 .await;
3405 let err = run_codex(&base_url, 0).await.expect_err("must fail");
3406 assert!(
3407 err.to_string()
3408 .starts_with("Codex response stream ended without a terminal event."),
3409 "{err}"
3410 );
3411 }
3412
3413 #[tokio::test]
3414 async fn codex_retries_429_then_succeeds() {
3415 let (base_url, counter) = spawn_mock_codex(1, StatusCode::TOO_MANY_REQUESTS).await;
3416 run_codex(&base_url, 2)
3417 .await
3418 .expect("429 is transient — must retry");
3419 assert_eq!(counter.load(Ordering::SeqCst), 2);
3420 }
3421
3422 #[tokio::test]
3423 async fn codex_zero_retries_fails_fast_with_status_error() {
3424 let (base_url, counter) =
3425 spawn_mock_codex(usize::MAX, StatusCode::INTERNAL_SERVER_ERROR).await;
3426 let err = run_codex(&base_url, 0).await.expect_err("must fail");
3427 assert_eq!(counter.load(Ordering::SeqCst), 1, "no retries budgeted");
3428 let msg = err.to_string();
3429 assert!(
3430 msg.starts_with("codex request failed: 500"),
3431 "status must survive for classification: {msg}"
3432 );
3433 }
3434
3435 #[tokio::test]
3436 async fn codex_does_not_retry_client_errors() {
3437 let (base_url, counter) = spawn_mock_codex(usize::MAX, StatusCode::BAD_REQUEST).await;
3438 let err = run_codex(&base_url, 3).await.expect_err("must fail");
3439 assert_eq!(
3440 counter.load(Ordering::SeqCst),
3441 1,
3442 "400 is deterministic — retrying it never helps"
3443 );
3444 assert!(err.to_string().starts_with("codex request failed: 400"));
3445 }
3446
3447 #[tokio::test]
3448 async fn codex_exhausted_retries_reports_last_status() {
3449 let (base_url, counter) =
3450 spawn_mock_codex(usize::MAX, StatusCode::SERVICE_UNAVAILABLE).await;
3451 let err = run_codex(&base_url, 1).await.expect_err("must fail");
3452 assert_eq!(
3453 counter.load(Ordering::SeqCst),
3454 2,
3455 "initial attempt + 1 retry"
3456 );
3457 assert!(err.to_string().starts_with("codex request failed: 503"));
3458 }
3459
3460 #[test]
3464 fn codex_retry_budget_lifts_configured_default_to_persistent_floor() {
3465 assert_eq!(codex_retry_budget(3), CODEX_PERSISTENT_RETRIES);
3466 assert_eq!(codex_retry_budget(0), CODEX_PERSISTENT_RETRIES);
3467 assert_eq!(codex_retry_budget(10), 10);
3468 }
3469
3470 #[test]
3473 fn codex_retry_budget_honors_larger_user_configuration() {
3474 assert_eq!(codex_retry_budget(15), 15);
3475 }
3476
3477 #[test]
3480 fn retry_delay_is_exponential_with_a_capped_tail() {
3481 assert_eq!(retry_delay(1).as_secs(), 1);
3482 assert_eq!(retry_delay(2).as_secs(), 2);
3483 assert_eq!(retry_delay(4).as_secs(), 8);
3484 assert_eq!(retry_delay(7).as_secs(), 64);
3485 assert_eq!(retry_delay(10).as_secs(), 64);
3486 }
3487
3488 const ECHO_SENTINEL: &str = "PH1-OAI-SENTINEL-2b8c4d-RAW-CONTENT";
3496
3497 struct CaptureWriter(Arc<std::sync::Mutex<Vec<u8>>>);
3499
3500 impl std::io::Write for CaptureWriter {
3501 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
3502 self.0.lock().unwrap().extend_from_slice(buf);
3503 Ok(buf.len())
3504 }
3505 fn flush(&mut self) -> std::io::Result<()> {
3506 Ok(())
3507 }
3508 }
3509
3510 fn capture_subscriber(
3511 buf: &Arc<std::sync::Mutex<Vec<u8>>>,
3512 ) -> impl tracing::Subscriber + Send + Sync {
3513 let sink = buf.clone();
3514 tracing_subscriber::fmt()
3515 .with_max_level(tracing::Level::TRACE)
3516 .with_writer(move || CaptureWriter(sink.clone()))
3517 .with_ansi(false)
3518 .finish()
3519 }
3520
3521 async fn spawn_hostile_echo_provider(status: StatusCode) -> String {
3525 let app = Router::new().fallback(move |body: String| async move {
3526 let envelope = serde_json::json!({
3527 "error": { "message": format!("ECHOED:{body}") }
3528 });
3529 (
3530 status,
3531 [("content-type", "application/json")],
3532 envelope.to_string(),
3533 )
3534 .into_response()
3535 });
3536 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3537 let addr = listener.local_addr().unwrap();
3538 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
3539 format!("http://{addr}")
3540 }
3541
3542 fn sentinel_request_body() -> Value {
3546 json!({
3547 "model": "gpt-test",
3548 "messages": [
3549 {"role": "system", "content": format!("system secret {ECHO_SENTINEL}")},
3550 {"role": "user", "content": ECHO_SENTINEL},
3551 ],
3552 "tools": [{
3553 "type": "function",
3554 "function": {
3555 "name": "ph1_secret_tool_zz",
3556 "description": "internal tool schema",
3557 "parameters": {"type": "object", "properties": {}}
3558 }
3559 }],
3560 })
3561 }
3562
3563 const BANNED_MARKERS: &[&str] = &[
3565 "ECHOED",
3566 ECHO_SENTINEL,
3567 "ph1_secret_tool_zz",
3568 "\"messages\"",
3569 "system secret",
3570 ];
3571
3572 fn assert_no_banned(haystack: &str, context: &str) {
3573 for banned in BANNED_MARKERS {
3574 assert!(
3575 !haystack.contains(banned),
3576 "provider body content `{banned}` leaked into {context}: {haystack}"
3577 );
3578 }
3579 }
3580
3581 #[tokio::test]
3582 async fn send_with_retries_non_retryable_error_never_surfaces_provider_body() {
3583 let url = spawn_hostile_echo_provider(StatusCode::BAD_REQUEST).await;
3584 let client = reqwest::Client::new();
3585 let body = sentinel_request_body();
3586
3587 let buf = Arc::new(std::sync::Mutex::new(Vec::new()));
3588 let _guard = tracing::subscriber::set_default(capture_subscriber(&buf));
3589
3590 let err = send_with_retries(
3591 "codex",
3592 &url,
3593 || client.post(&url).json(&body),
3594 &tokio_util::sync::CancellationToken::new(),
3595 3,
3596 &mut tr::StreamAttempt::new(None),
3597 )
3598 .await
3599 .expect_err("400 must fail fast");
3600
3601 let msg = err.to_string();
3602 assert!(
3603 msg.starts_with("codex request failed: 400"),
3604 "status + label must survive for classification/usability: {msg}"
3605 );
3606 assert_no_banned(&msg, "the surfaced error");
3607 let logs = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
3608 assert_no_banned(&logs, "tracing output");
3609 }
3610
3611 #[tokio::test(start_paused = true)]
3612 async fn send_with_retries_retry_logs_never_contain_provider_body() {
3613 let url = spawn_hostile_echo_provider(StatusCode::SERVICE_UNAVAILABLE).await;
3614 let client = reqwest::Client::new();
3615 let body = sentinel_request_body();
3616
3617 let buf = Arc::new(std::sync::Mutex::new(Vec::new()));
3618 let _guard = tracing::subscriber::set_default(capture_subscriber(&buf));
3619
3620 let err = send_with_retries(
3621 "codex",
3622 &url,
3623 || client.post(&url).json(&body),
3624 &tokio_util::sync::CancellationToken::new(),
3625 1,
3626 &mut tr::StreamAttempt::new(None),
3627 )
3628 .await
3629 .expect_err("persistent 503 must exhaust the budget");
3630
3631 let msg = err.to_string();
3632 assert!(
3633 msg.starts_with("codex request failed: 503"),
3634 "status + label must survive after exhaustion: {msg}"
3635 );
3636 assert_no_banned(&msg, "the surfaced error");
3637
3638 let logs = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
3639 assert!(
3640 logs.contains("retry 1/1"),
3641 "retry accounting must stay observable: {logs}"
3642 );
3643 assert!(
3644 logs.contains("503"),
3645 "retry log must keep the status for diagnosis: {logs}"
3646 );
3647 assert_no_banned(&logs, "retry tracing output");
3648 }
3649}