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 let Some(stream_options) = stream_options {
234 body.insert(
235 "stream_options".to_string(),
236 serde_json::to_value(stream_options)?,
237 );
238 }
239 if let Some(max_tokens) = max_tokens {
240 body.insert("max_tokens".to_string(), json!(max_tokens));
241 }
242 if let Some(temperature) = temperature {
243 body.insert("temperature".to_string(), json!(temperature));
244 }
245 if let Some(tools) = tools_opt {
246 body.insert("tools".to_string(), serde_json::to_value(tools)?);
247 }
248 super::reasoning::apply_openai_reasoning_params(
249 &mut body,
250 super::reasoning::provider_for_key(&cfg.provider),
251 &cfg.model,
252 thinking_budget,
253 reasoning_level,
254 );
255 let body = Value::Object(body);
256
257 tracing::debug!(provider=%cfg.provider, model=%cfg.model, "openai stream request via broker proxy");
258
259 let (proxy_request, body_bytes) = crate::auth::ProxyRequest::post_json_exact(
265 cfg.provider.clone(),
266 "/chat/completions",
267 body,
268 true,
269 )?;
270 let tracer = tr::begin_openai_tracer(
274 trace,
275 &cfg.provider,
276 &cfg.model,
277 if exact_wire_bytes {
278 crate::runtime::trace::TransportKind::OpenAiChatCompletions
279 } else {
280 crate::runtime::trace::TransportKind::CloudProxy
281 },
282 &format!("{}/chat/completions", cfg.base_url.trim_end_matches('/')),
283 exact_wire_bytes.then_some(body_bytes.as_ref()),
284 messages,
285 system_prompt.as_deref(),
286 tools_schema,
287 tr::renamed_tool_losses(&name_map),
288 )
289 .await;
290 if let Some(tracer) = &tracer {
296 trace.capture_request_content(tracer.request_id(), body_bytes.as_ref());
297 }
298 let mut attempt = tr::StreamAttempt::new(tracer);
299
300 let stream = broker
303 .proxy_stream(proxy_request)
304 .await
305 .map_err(|e| {
308 format!(
309 "openai request failed: {}",
310 super::net::redact_provider_proxy_error(&e.to_string())
311 )
312 });
313 let mut stream = match stream {
314 Ok(stream) => {
315 attempt.mark_headers();
316 stream
317 }
318 Err(msg) => {
319 let status = tr::broker_error_status(&msg);
322 let code = status.map_or_else(|| "broker_error".to_string(), |s| format!("http_{s}"));
323 attempt.finish_failed(&code, status, None);
324 return Err(msg.into());
325 }
326 };
327
328 let mut decoder = StreamDecoder::new();
329 let mut accumulated_text = String::new();
330 let mut tool_use_blocks: Vec<Value> = Vec::new();
331 let mut buf = bytes::BytesMut::with_capacity(8 * 1024);
332 let mut sink: Vec<OaiEvent> = Vec::with_capacity(4);
333 let mut trace_usage: Option<crate::runtime::trace::UsageMeta> = None;
334
335 while let Some(chunk) = tokio::select! {
336 chunk = stream.next() => chunk,
337 _ = cancel.cancelled() => {
338 attempt.finish_canceled(None, trace_usage);
339 return Err("request canceled".into());
340 }
341 } {
342 let chunk = match chunk {
343 Ok(chunk) => {
344 attempt.mark_first_byte();
345 chunk
346 }
347 Err(e) => {
348 attempt.finish_failed("stream_error", None, None);
349 return Err(e.into());
350 }
351 };
352 buf.extend_from_slice(&chunk);
353
354 while let Some(nl) = memchr::memchr(b'\n', &buf) {
356 let line_bytes = buf.split_to(nl + 1); let line = std::str::from_utf8(&line_bytes[..nl]).unwrap_or("");
358
359 sink.clear();
360 decoder.push_line(line, &mut sink);
361 capture_oai_trace_signals(&sink, &mut attempt, &mut trace_usage);
362 handle_events(
363 &sink,
364 tx,
365 &mut accumulated_text,
366 &mut tool_use_blocks,
367 &name_map,
368 suppress_stream_deltas,
369 );
370 }
371 }
372
373 if !buf.is_empty() {
375 let line = std::str::from_utf8(&buf).unwrap_or("");
376 sink.clear();
377 decoder.push_line(line, &mut sink);
378 capture_oai_trace_signals(&sink, &mut attempt, &mut trace_usage);
379 handle_events(
380 &sink,
381 tx,
382 &mut accumulated_text,
383 &mut tool_use_blocks,
384 &name_map,
385 suppress_stream_deltas,
386 );
387 }
388 sink.clear();
389 decoder.finish(&mut sink);
390 capture_oai_trace_signals(&sink, &mut attempt, &mut trace_usage);
391 handle_events(
392 &sink,
393 tx,
394 &mut accumulated_text,
395 &mut tool_use_blocks,
396 &name_map,
397 suppress_stream_deltas,
398 );
399
400 let stop_reason = decoder
403 .finish_reason
404 .as_deref()
405 .map(tr::stop_reason_from_finish_reason);
406 attempt.finish_success(None, None, stop_reason, trace_usage);
408
409 let mut content: Vec<Value> = Vec::new();
411 if !accumulated_text.is_empty() {
412 content.push(json!({"type": "text", "text": accumulated_text}));
413 }
414 content.extend(tool_use_blocks);
415
416 Ok(json!({
417 "role": "assistant",
418 "content": content,
419 }))
420}
421
422#[allow(clippy::too_many_arguments)]
423pub(crate) async fn call_codex_stream_inner(
424 cfg: &ProviderConfig,
425 client: &reqwest::Client,
426 broker: &std::sync::Arc<dyn crate::auth::CredentialBroker>,
427 tools_schema: &[Value],
428 system_prompt: &Option<String>,
429 messages: &[crate::SharedMessage],
430 tx: &mpsc::UnboundedSender<StreamEvent>,
431 temperature: Option<f32>,
432 max_tokens: Option<u32>,
433 reasoning_level: agent_core::reasoning::ReasoningLevel,
434 codex_request_role: crate::runtime::openai::catalog::CodexRequestRole,
435 cancel: &tokio_util::sync::CancellationToken,
436 max_retries: u32,
437 trace: &crate::runtime::trace::TraceContext,
438) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
439 use crate::runtime::openai::catalog::plan_codex_execution;
442 let qualified_model = format!("{}/{}", cfg.provider, cfg.model);
443 let plan =
444 match plan_codex_execution(&qualified_model, reasoning_level, codex_request_role, None) {
445 Ok(plan) => plan,
446 Err(error) => {
447 tracing::debug!(
448 event = "codex_mode_plan",
449 qualified_model = %qualified_model,
450 requested_level = %reasoning_level,
451 runtime_role = codex_request_role.as_str(),
452 decision = "deny",
453 deny_code = error.code().as_str(),
454 network_attempted = false,
455 "Codex execution plan denied"
456 );
457 return Err(Box::new(error));
458 }
459 };
460 if plan.automatic_delegation() && !codex_has_required_delegation_tools(tools_schema) {
461 tracing::debug!(
462 event = "codex_mode_plan",
463 qualified_model = %qualified_model,
464 requested_level = %reasoning_level,
465 runtime_role = codex_request_role.as_str(),
466 decision = "deny",
467 deny_code = "ultra_requires_subagent_tools",
468 network_attempted = false,
469 "Codex execution plan denied"
470 );
471 return Err(
472 "Ultra requires subagent_start, subagent_status, and subagent_collect tools".into(),
473 );
474 }
475 tracing::debug!(
476 event = "codex_mode_plan",
477 qualified_model = %plan.qualified_model,
478 requested_level = %plan.selected_level,
479 execution_mode = plan.mode.as_str(),
480 wire_effort = plan.wire_effort_label(),
481 capability_source = plan.capability_source.map_or("none", |source| source.as_str()),
482 multi_agent_version = plan.multi_agent_version_label(),
483 runtime_role = plan.request_role.as_str(),
484 multi_agent_mode = plan.multi_agent_mode_label(),
485 automatic_delegation = plan.automatic_delegation(),
486 decision = "allow",
487 network_attempted = false,
488 "Codex execution plan allowed"
489 );
490 let access = broker
494 .access_token(crate::auth::OAuthProviderId::OpenAiCodex)
495 .await
496 .map_err(|e| e.to_string())?
497 .token;
498 let account_id = crate::auth::extract_codex_account_id(&access)
500 .ok_or("Failed to extract ChatGPT account id from Codex token — run `synaps login --provider openai-codex`")?;
501
502 let (oai_tools, name_map) = translate::tools_to_oai(tools_schema);
503 let oai_messages = translate::messages_to_oai(messages, system_prompt, &name_map);
504 let tools: Vec<Value> = oai_tools
505 .into_iter()
506 .map(|tool| {
507 json!({
508 "type": "function",
509 "name": tool.function.name,
510 "description": tool.function.description.unwrap_or_default(),
511 "parameters": tool.function.parameters,
512 })
513 })
514 .collect();
515
516 let instructions = codex_instructions(system_prompt);
519 let mut input_items = codex_input_messages(oai_messages);
520 let prompt_cache_key = codex_prompt_cache_key(&instructions, input_items.first());
523 insert_codex_multi_agent_mode(&mut input_items, &plan);
524 let input = serde_json::Value::Array(input_items);
525 let body = build_codex_body(
526 &cfg.model,
527 &plan,
528 input,
529 instructions,
530 tools,
531 temperature,
532 max_tokens,
533 &prompt_cache_key,
534 );
535
536 let url = format!(
537 "{}/codex/responses",
538 cfg.base_url
539 .trim_end_matches('/')
540 .trim_end_matches("/codex")
541 );
542 tracing::debug!(url=%url, model=%cfg.model, "codex stream request");
543
544 let body_bytes = bytes::Bytes::from(serde_json::to_vec(&body)?);
548 let tracer = tr::begin_openai_tracer(
550 trace,
551 &cfg.provider,
552 &cfg.model,
553 crate::runtime::trace::TransportKind::OpenAiResponses,
554 &url,
555 Some(body_bytes.as_ref()),
556 messages,
557 system_prompt.as_deref(),
558 tools_schema,
559 tr::renamed_tool_losses(&name_map),
560 )
561 .await;
562 if let Some(tracer) = &tracer {
568 trace.capture_request_content(tracer.request_id(), body_bytes.as_ref());
569 }
570 let mut attempt = tr::StreamAttempt::new(tracer);
571 let mut stream_retry = 0u32;
572
573 loop {
574 let resp = send_with_retries(
575 "codex",
576 &url,
577 || {
578 client
579 .post(&url)
580 .bearer_auth(&access)
581 .header("chatgpt-account-id", account_id.as_str())
582 .header("originator", "synaps")
583 .header("OpenAI-Beta", "responses=experimental")
584 .header("content-type", "application/json")
585 .header("accept", "text/event-stream")
586 .body(body_bytes.clone())
587 },
588 cancel,
589 max_retries.saturating_sub(stream_retry),
590 &mut attempt,
591 )
592 .await?;
593 let http_status = Some(resp.status().as_u16());
595 let trace_rid = tr::provider_request_id_from_headers(resp.headers());
596
597 let mut accumulated_text = String::new();
598 let mut parser = CodexSseDecoder::default();
599 let mut buf = bytes::BytesMut::with_capacity(8 * 1024);
600 let mut stream = resp.bytes_stream();
601
602 while let Some(chunk) = tokio::select! {
603 chunk = stream.next() => chunk,
604 _ = cancel.cancelled() => {
605 attempt.finish_canceled(http_status, parser.trace_usage());
606 return Err("request canceled".into());
607 }
608 } {
609 let chunk = match chunk {
610 Ok(chunk) => {
611 attempt.mark_first_byte();
612 chunk
613 }
614 Err(e) => {
615 attempt.finish_failed("stream_error", http_status, trace_rid.clone());
616 return Err(e.into());
617 }
618 };
619 buf.extend_from_slice(&chunk);
620 while let Some(nl) = memchr::memchr(b'\n', &buf) {
621 let line_bytes = buf.split_to(nl + 1);
622 let line = std::str::from_utf8(&line_bytes[..nl]).unwrap_or("");
623 parser.push_line(line, tx, &mut accumulated_text);
624 }
625 if parser.saw_model_event {
626 attempt.mark_first_model_event();
627 }
628 }
629 if !buf.is_empty() {
630 let line = std::str::from_utf8(&buf).unwrap_or("");
631 parser.push_line(line, tx, &mut accumulated_text);
632 }
633 parser.finish();
634 if parser.saw_model_event {
638 attempt.mark_first_model_event();
639 }
640 if let Err(failure) = parser.terminal_result() {
641 if failure.code == "responses_empty" && stream_retry < max_retries {
642 stream_retry += 1;
643 let delay = retry_delay(stream_retry);
644 attempt.attempt_failed(
645 crate::runtime::trace::RetryClass::Other,
646 delay,
647 http_status,
648 trace_rid,
649 failure.code,
650 );
651 tracing::warn!(
652 "codex stream retry {stream_retry}/{max_retries} after {delay:?}: completed without usable output"
653 );
654 tokio::select! {
655 _ = tokio::time::sleep(delay) => {}
656 _ = cancel.cancelled() => {
657 attempt.finish_canceled(None, None);
658 return Err("request canceled".into());
659 }
660 }
661 attempt.restart_clock();
662 continue;
663 }
664 attempt.finish_failed(failure.code, http_status, trace_rid);
665 return Err(failure.message.into());
666 }
667 attempt.finish_success(http_status, trace_rid, None, parser.trace_usage());
668
669 let mut content: Vec<Value> = Vec::new();
670 if !accumulated_text.is_empty() {
671 content.push(json!({"type": "text", "text": accumulated_text}));
672 }
673 content.extend(translate::tool_calls_to_content_blocks(
674 &parser.completed_tools,
675 &name_map,
676 ));
677
678 return Ok(json!({
679 "role": "assistant",
680 "content": content,
681 }));
682 }
683}
684
685pub(crate) fn codex_prompt_cache_key(instructions: &str, first_item: Option<&Value>) -> String {
697 use sha2::{Digest, Sha256};
698 let mut hasher = Sha256::new();
699 hasher.update(instructions.as_bytes());
700 if let Some(item) = first_item {
701 hasher.update(item.to_string().as_bytes());
702 }
703 format!("synaps-{:x}", hasher.finalize())
704}
705
706#[allow(clippy::too_many_arguments)] pub(crate) fn build_codex_body(
723 model: &str,
724 plan: &crate::runtime::openai::catalog::CodexExecutionPlan,
725 input: serde_json::Value,
726 instructions: String,
727 tools: Vec<serde_json::Value>,
728 temperature: Option<f32>,
729 max_tokens: Option<u32>,
730 prompt_cache_key: &str,
731) -> serde_json::Value {
732 let mut body = json!({
733 "model": model,
734 "store": false,
735 "stream": true,
736 "instructions": instructions,
737 "input": input,
738 "tool_choice": "auto",
739 "parallel_tool_calls": true,
740 "include": ["reasoning.encrypted_content"],
741 "text": { "verbosity": "medium" },
742 "prompt_cache_key": prompt_cache_key,
743 });
744 if let Some(effort) = plan.wire_effort {
745 body["reasoning"] = json!({ "effort": effort.as_str() });
746 }
747 if !tools.is_empty() {
748 body["tools"] = serde_json::Value::Array(tools);
749 }
750 if let Some(temp) = temperature {
751 body["temperature"] = json!(temp);
752 }
753 if let Some(max) = max_tokens {
754 body["max_output_tokens"] = json!(max);
755 }
756 body
757}
758
759const CODEX_MULTI_AGENT_MODE_OPEN: &str = "<multi_agent_mode>";
760const CODEX_MULTI_AGENT_MODE_CLOSE: &str = "</multi_agent_mode>";
761const 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.";
762const 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.";
763
764fn codex_multi_agent_mode_item(
765 plan: &crate::runtime::openai::catalog::CodexExecutionPlan,
766) -> Option<Value> {
767 use crate::runtime::openai::catalog::CodexMultiAgentMode;
768 let body = match plan.multi_agent_mode? {
769 CodexMultiAgentMode::ExplicitRequestOnly => CODEX_EXPLICIT_REQUEST_ONLY_TEXT,
770 CodexMultiAgentMode::Proactive => CODEX_PROACTIVE_MULTI_AGENT_TEXT,
771 };
772 Some(json!({
773 "type": "message",
774 "role": "developer",
775 "content": [{
776 "type": "input_text",
777 "text": format!("{CODEX_MULTI_AGENT_MODE_OPEN}{body}{CODEX_MULTI_AGENT_MODE_CLOSE}"),
778 }],
779 }))
780}
781
782fn is_codex_multi_agent_mode_item(item: &Value) -> bool {
783 item.pointer("/content/0/text")
784 .and_then(Value::as_str)
785 .is_some_and(|text| {
786 text.trim_start().starts_with(CODEX_MULTI_AGENT_MODE_OPEN)
787 && text.trim_end().ends_with(CODEX_MULTI_AGENT_MODE_CLOSE)
788 })
789}
790
791fn insert_codex_multi_agent_mode(
792 input: &mut Vec<Value>,
793 plan: &crate::runtime::openai::catalog::CodexExecutionPlan,
794) {
795 input.retain(|item| !is_codex_multi_agent_mode_item(item));
796 let Some(item) = codex_multi_agent_mode_item(plan) else {
797 return;
798 };
799 input.insert(0, item);
809}
810
811fn codex_has_required_delegation_tools(tools_schema: &[Value]) -> bool {
812 ["subagent_start", "subagent_status", "subagent_collect"]
813 .into_iter()
814 .all(|required| {
815 tools_schema.iter().any(|tool| {
816 tool.get("name").and_then(Value::as_str) == Some(required)
817 || tool.pointer("/function/name").and_then(Value::as_str) == Some(required)
818 })
819 })
820}
821
822const 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]";
823
824fn codex_instructions(system_prompt: &Option<String>) -> String {
825 let mut instructions = system_prompt.clone().unwrap_or_default();
826 if instructions.contains("[Synaps autonomous harness policy]") {
827 return instructions;
828 }
829 instructions.push_str(CODEX_AUTONOMOUS_LOOP_POLICY);
830 instructions
831}
832
833fn codex_input_messages(messages: Vec<ChatMessage>) -> Vec<Value> {
834 let mut out = Vec::new();
835 for msg in messages {
836 if let Some(tool_calls) = msg.tool_calls {
837 for call in tool_calls {
838 let mut item = json!({
845 "type": "function_call",
846 "call_id": call.id,
847 "name": call.function.name,
848 "arguments": call.function.arguments,
849 });
850 if call.id.starts_with("fc") {
851 item["id"] = Value::from(call.id.clone());
852 }
853 out.push(item);
854 }
855 continue;
856 }
857 if msg.role == "tool" {
858 if let Some(call_id) = msg.tool_call_id {
861 out.push(json!({
862 "type": "function_call_output",
863 "call_id": call_id,
864 "output": msg.content.unwrap_or_default(),
865 }));
866 }
867 continue;
868 }
869 out.push(json!({
870 "role": msg.role,
871 "content": msg.content.unwrap_or_default(),
872 }));
873 }
874 out
875}
876
877#[derive(Clone, Copy, Debug, Eq, PartialEq)]
878struct ResponsesStreamFailure {
879 code: &'static str,
880 message: &'static str,
881}
882
883#[derive(Default)]
884struct CodexSseDecoder {
885 buffer: String,
886 active_tools: Vec<CodexToolAccumulator>,
887 completed_tools: Vec<ToolCall>,
888 saw_model_event: bool,
891 terminal_success: bool,
895 terminal_failure: Option<ResponsesStreamFailure>,
896 emitted_output: bool,
897 observed_usage: Option<(u64, u64, u64)>,
900}
901
902#[derive(Default)]
903struct CodexToolAccumulator {
904 id: String,
905 name: String,
906 arguments: String,
907 started: bool,
908}
909
910fn parse_tool_arguments(raw: &str) -> Value {
918 if raw.trim().is_empty() {
919 return json!({});
920 }
921 match serde_json::from_str(raw) {
922 Ok(v) => v,
923 Err(e) => json!({ "__parse_error": format!("invalid tool input JSON: {}", e) }),
924 }
925}
926
927fn sanitize_error_identifier(raw: Option<&str>) -> &str {
936 match raw {
937 None => "absent",
938 Some(s) => {
939 let identifier_shaped = !s.is_empty()
940 && s.len() <= 64
941 && s.bytes()
942 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.');
943 if identifier_shaped
944 && s.bytes()
945 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
946 {
947 s
949 } else if identifier_shaped {
950 "unlisted_identifier"
953 } else {
954 "unsafe_or_freeform"
955 }
956 }
957 }
958}
959
960fn user_message_for_terminal_failure(error_kind: &str, error_code: &str) -> &'static str {
966 if error_code == "context_length_exceeded" || error_kind == "context_length_exceeded" {
969 return "Codex rejected the request: the conversation exceeds this model's context window. Run /compact or start a fresh session to continue.";
970 }
971 "Codex response failed in stream. Provider error details withheld because they can echo request content."
972}
973
974impl CodexSseDecoder {
975 fn push_line(
976 &mut self,
977 line: &str,
978 tx: &mpsc::UnboundedSender<StreamEvent>,
979 text_acc: &mut String,
980 ) {
981 let line = line.trim_end_matches('\r');
982 if line.is_empty() {
983 if !self.buffer.is_empty() {
984 let payload = std::mem::take(&mut self.buffer);
985 self.push_payload(&payload, tx, text_acc);
986 }
987 return;
988 }
989 let Some(data) = line.strip_prefix("data:").map(str::trim_start) else {
990 return;
991 };
992 if data == "[DONE]" {
993 self.terminal_success = true;
994 self.finish();
995 return;
996 }
997 self.buffer.push_str(data);
998 }
999
1000 fn push_payload(
1001 &mut self,
1002 payload: &str,
1003 tx: &mpsc::UnboundedSender<StreamEvent>,
1004 text_acc: &mut String,
1005 ) {
1006 let Ok(event) = serde_json::from_str::<Value>(payload) else {
1007 tracing::debug!(
1008 payload_bytes = payload.len(),
1009 "discarding malformed Responses SSE payload"
1010 );
1011 return;
1012 };
1013 let event_type = event
1014 .get("type")
1015 .and_then(Value::as_str)
1016 .unwrap_or_default();
1017 if matches!(
1018 event_type,
1019 "response.output_text.delta"
1020 | "response.output_item.added"
1021 | "response.function_call_arguments.delta"
1022 | "response.output_item.done"
1023 | "response.completed"
1024 | "response.done"
1025 | "response.failed"
1026 | "response.incomplete"
1027 | "error"
1028 ) {
1029 self.saw_model_event = true;
1030 }
1031 match event_type {
1032 "response.output_text.delta" => {
1033 if let Some(delta) = event.get("delta").and_then(Value::as_str) {
1034 if !delta.is_empty() {
1035 self.emitted_output = true;
1036 }
1037 text_acc.push_str(delta);
1038 let _ = tx.send(StreamEvent::Llm(crate::runtime::types::LlmEvent::Text(
1039 delta.to_string(),
1040 )));
1041 }
1042 }
1043 "response.output_item.added" => {
1044 if let Some(item) = event.get("item") {
1045 let idx = event
1046 .get("output_index")
1047 .and_then(Value::as_u64)
1048 .unwrap_or(0) as usize;
1049 self.add_tool_from_item(idx, item, tx);
1050 }
1051 }
1052 "response.function_call_arguments.delta" => {
1053 let idx = event
1054 .get("output_index")
1055 .and_then(Value::as_u64)
1056 .unwrap_or(0) as usize;
1057 let delta = event
1058 .get("delta")
1059 .and_then(Value::as_str)
1060 .unwrap_or_default();
1061 if !delta.is_empty() {
1062 let tool = self.ensure_tool(idx);
1063 tool.arguments.push_str(delta);
1064 let tool_id = tool.id.clone();
1065 let _ = tx.send(StreamEvent::Llm(
1066 crate::runtime::types::LlmEvent::ToolUseDelta {
1067 tool_id,
1068 delta: delta.to_string(),
1069 },
1070 ));
1071 }
1072 }
1073 "response.output_item.done" => {
1074 if let Some(item) = event.get("item") {
1075 let idx = event
1076 .get("output_index")
1077 .and_then(Value::as_u64)
1078 .unwrap_or(0) as usize;
1079 self.complete_tool_from_item(idx, item, tx);
1080 }
1081 }
1082 "response.completed" | "response.done" => {
1083 self.push_usage(&event, tx);
1084 self.terminal_success = true;
1085 self.finish();
1086 }
1087 "response.failed" | "error" => {
1088 self.push_usage(&event, tx);
1089 let error_obj = event
1094 .get("response")
1095 .and_then(|r| r.get("error"))
1096 .or_else(|| event.get("error"));
1097 let error_kind = sanitize_error_identifier(
1098 error_obj
1099 .and_then(|e| e.get("type"))
1100 .and_then(Value::as_str),
1101 );
1102 let error_code = sanitize_error_identifier(
1103 error_obj
1104 .and_then(|e| e.get("code"))
1105 .and_then(Value::as_str),
1106 );
1107 tracing::warn!(
1108 event_type,
1109 error_kind,
1110 error_code,
1111 "codex stream terminal failure (provider message withheld)"
1112 );
1113 self.terminal_failure = Some(ResponsesStreamFailure {
1114 code: "responses_failed",
1115 message: user_message_for_terminal_failure(error_kind, error_code),
1116 });
1117 self.finish();
1118 }
1119 "response.incomplete" => {
1120 self.push_usage(&event, tx);
1121 let reason = sanitize_error_identifier(
1122 event
1123 .get("response")
1124 .and_then(|r| r.get("incomplete_details"))
1125 .and_then(|d| d.get("reason"))
1126 .and_then(Value::as_str),
1127 );
1128 tracing::warn!(reason, "codex stream terminal incomplete");
1129 self.terminal_failure = Some(ResponsesStreamFailure {
1130 code: "responses_incomplete",
1131 message: "Codex response was incomplete. Retry the request or reduce the requested output/context size.",
1132 });
1133 self.finish();
1134 }
1135 _ => {
1136 if !event_type.is_empty() {
1137 tracing::debug!(event_type, "ignoring unknown Responses SSE event");
1138 }
1139 }
1140 }
1141 }
1142
1143 fn terminal_result(&self) -> Result<(), ResponsesStreamFailure> {
1144 if let Some(failure) = self.terminal_failure {
1145 return Err(failure);
1146 }
1147 if self.terminal_success {
1148 if self.emitted_output || !self.completed_tools.is_empty() {
1149 Ok(())
1150 } else {
1151 Err(ResponsesStreamFailure {
1152 code: "responses_empty",
1153 message: "Codex completed without text or tool output. Retry the request.",
1154 })
1155 }
1156 } else {
1157 Err(ResponsesStreamFailure {
1158 code: "responses_missing_terminal",
1159 message: "Codex response stream ended without a terminal event. Retry the request.",
1160 })
1161 }
1162 }
1163
1164 fn ensure_tool(&mut self, idx: usize) -> &mut CodexToolAccumulator {
1165 while self.active_tools.len() <= idx {
1166 self.active_tools.push(CodexToolAccumulator::default());
1167 }
1168 &mut self.active_tools[idx]
1169 }
1170
1171 fn add_tool_from_item(
1172 &mut self,
1173 idx: usize,
1174 item: &Value,
1175 tx: &mpsc::UnboundedSender<StreamEvent>,
1176 ) {
1177 if item.get("type").and_then(Value::as_str) != Some("function_call") {
1178 return;
1179 }
1180 let tool = self.ensure_tool(idx);
1181 if let Some(id) = item
1182 .get("call_id")
1183 .or_else(|| item.get("id"))
1184 .and_then(Value::as_str)
1185 {
1186 tool.id = id.to_string();
1187 }
1188 if let Some(name) = item.get("name").and_then(Value::as_str) {
1189 tool.name = name.to_string();
1190 }
1191 if !tool.started && !tool.name.is_empty() {
1192 tool.started = true;
1193 let _ = tx.send(StreamEvent::Llm(
1194 crate::runtime::types::LlmEvent::ToolUseStart {
1195 tool_name: tool.name.clone(),
1196 tool_id: tool.id.clone(),
1197 },
1198 ));
1199 }
1200 }
1201
1202 fn complete_tool_from_item(
1203 &mut self,
1204 idx: usize,
1205 item: &Value,
1206 tx: &mpsc::UnboundedSender<StreamEvent>,
1207 ) {
1208 if item.get("type").and_then(Value::as_str) != Some("function_call") {
1209 return;
1210 }
1211 let tool = self.ensure_tool(idx);
1212 if let Some(id) = item
1213 .get("call_id")
1214 .or_else(|| item.get("id"))
1215 .and_then(Value::as_str)
1216 {
1217 tool.id = id.to_string();
1218 }
1219 if let Some(name) = item.get("name").and_then(Value::as_str) {
1220 tool.name = name.to_string();
1221 }
1222 if let Some(arguments) = item.get("arguments").and_then(Value::as_str) {
1223 tool.arguments = arguments.to_string();
1224 }
1225 if !tool.started && !tool.name.is_empty() {
1226 tool.started = true;
1227 let _ = tx.send(StreamEvent::Llm(
1228 crate::runtime::types::LlmEvent::ToolUseStart {
1229 tool_name: tool.name.clone(),
1230 tool_id: tool.id.clone(),
1231 },
1232 ));
1233 }
1234 let completed = if !tool.id.is_empty() && !tool.name.is_empty() {
1235 Some(ToolCall {
1236 id: tool.id.clone(),
1237 kind: "function".to_string(),
1238 function: super::types::FunctionCall {
1239 name: tool.name.clone(),
1240 arguments: tool.arguments.clone(),
1241 },
1242 })
1243 } else {
1244 None
1245 };
1246 if let Some(call) = completed {
1247 if self.completed_tools.iter().any(|done| done.id == call.id) {
1248 return;
1249 }
1250 self.emitted_output = true;
1251 let input = parse_tool_arguments(&call.function.arguments);
1259 let _ = tx.send(StreamEvent::Llm(crate::runtime::types::LlmEvent::ToolUse {
1260 tool_name: call.function.name.clone(),
1261 tool_id: call.id.clone(),
1262 input,
1263 }));
1264 self.completed_tools.push(ToolCall {
1265 id: call.id,
1266 kind: call.kind,
1267 function: call.function,
1268 });
1269 }
1270 }
1271
1272 fn push_usage(&mut self, event: &Value, tx: &mpsc::UnboundedSender<StreamEvent>) {
1273 let usage = event
1274 .get("response")
1275 .and_then(|r| r.get("usage"))
1276 .or_else(|| event.get("usage"));
1277 let input = usage
1278 .and_then(|u| u.get("input_tokens"))
1279 .and_then(Value::as_u64)
1280 .unwrap_or(0);
1281 let output = usage
1282 .and_then(|u| u.get("output_tokens"))
1283 .and_then(Value::as_u64)
1284 .unwrap_or(0);
1285 let cached = usage
1292 .and_then(|u| u.pointer("/input_tokens_details/cached_tokens"))
1293 .and_then(Value::as_u64)
1294 .unwrap_or(0)
1295 .min(input);
1296 if input > 0 || output > 0 {
1297 if self.observed_usage.is_none() {
1300 self.observed_usage = Some((input, output, cached));
1301 }
1302 let _ = tx.send(StreamEvent::Session(
1303 crate::runtime::types::SessionEvent::Usage {
1304 input_tokens: input - cached,
1305 output_tokens: output,
1306 cache_read_input_tokens: cached,
1307 cache_creation_input_tokens: 0,
1308 cache_creation_5m: None,
1309 cache_creation_1h: None,
1310 model: None,
1311 },
1312 ));
1313 }
1314 }
1315
1316 fn trace_usage(&self) -> Option<crate::runtime::trace::UsageMeta> {
1318 self.observed_usage
1319 .map(|(input, output, cached)| tr::provider_usage(input, output, cached))
1320 }
1321
1322 fn finish(&mut self) {
1323 for tool in self.active_tools.drain(..) {
1324 if !tool.id.is_empty()
1325 && !tool.name.is_empty()
1326 && !self.completed_tools.iter().any(|done| done.id == tool.id)
1327 {
1328 self.emitted_output = true;
1329 self.completed_tools.push(ToolCall {
1330 id: tool.id,
1331 kind: "function".to_string(),
1332 function: super::types::FunctionCall {
1333 name: tool.name,
1334 arguments: tool.arguments,
1335 },
1336 });
1337 }
1338 }
1339 }
1340}
1341
1342fn handle_events(
1343 events: &[OaiEvent],
1344 tx: &mpsc::UnboundedSender<StreamEvent>,
1345 text_acc: &mut String,
1346 tool_blocks: &mut Vec<Value>,
1347 name_map: &translate::ToolNameMap,
1348 suppress_stream_deltas: bool,
1349) {
1350 for ev in events {
1351 if let OaiEvent::TextDelta(t) = ev {
1352 text_acc.push_str(t);
1353 }
1354 if let OaiEvent::ToolCallsComplete { calls, .. } = ev {
1355 tool_blocks.extend(translate::tool_calls_to_content_blocks(calls, name_map));
1356 }
1357 if !suppress_stream_deltas {
1358 if let Some(se) = translate::oai_event_to_llm(ev) {
1359 let _ = tx.send(se);
1360 }
1361 }
1362 }
1363}
1364
1365#[cfg(test)]
1366mod codex_input_messages_tests {
1367 use super::super::types::{ChatMessage, FunctionCall, ToolCall};
1383 use super::*;
1384
1385 fn sample_tool_call() -> ToolCall {
1386 ToolCall {
1387 id: "call_nZYquCuGUh8Qs9H51dwHMDgs".to_string(),
1388 kind: "function".to_string(),
1389 function: FunctionCall {
1390 name: "bash".to_string(),
1391 arguments: r#"{"command":"ls"}"#.to_string(),
1392 },
1393 }
1394 }
1395
1396 #[test]
1397 fn codex_instructions_appends_autonomous_loop_policy() {
1398 let instructions = codex_instructions(&Some("Project-specific rules.".to_string()));
1399 assert!(instructions.contains("Project-specific rules."));
1400 assert!(instructions.contains("Do not stop at phase boundaries"));
1401 assert!(instructions.contains("Do not ask the user whether to continue"));
1402 assert!(
1403 instructions.contains("continue autonomously until the full requested job is complete")
1404 );
1405 }
1406
1407 #[test]
1408 fn function_call_input_omits_non_fc_id() {
1409 let messages = vec![ChatMessage::assistant_tool_calls(vec![sample_tool_call()])];
1410 let out = codex_input_messages(messages);
1411 assert_eq!(out.len(), 1, "one tool_call → one input item");
1412 let item = &out[0];
1413 assert_eq!(
1414 item.get("type").and_then(Value::as_str),
1415 Some("function_call")
1416 );
1417 assert!(
1418 item.get("id").is_none(),
1419 "must not echo a non-`fc_` id back; got {:?}",
1420 item.get("id"),
1421 );
1422 assert_eq!(
1423 item.get("call_id").and_then(Value::as_str),
1424 Some("call_nZYquCuGUh8Qs9H51dwHMDgs"),
1425 );
1426 assert_eq!(item.get("name").and_then(Value::as_str), Some("bash"));
1427 }
1428
1429 #[test]
1430 fn function_call_input_keeps_real_fc_id() {
1431 let mut call = sample_tool_call();
1434 call.id = "fc_abc123".to_string();
1435 let messages = vec![ChatMessage::assistant_tool_calls(vec![call])];
1436 let out = codex_input_messages(messages);
1437 let item = &out[0];
1438 assert_eq!(item.get("id").and_then(Value::as_str), Some("fc_abc123"));
1439 assert_eq!(
1440 item.get("call_id").and_then(Value::as_str),
1441 Some("fc_abc123")
1442 );
1443 }
1444
1445 #[test]
1446 fn function_call_output_round_trips_call_id() {
1447 let messages = vec![ChatMessage::tool_result(
1449 "call_nZYquCuGUh8Qs9H51dwHMDgs",
1450 "bash",
1451 "total 0",
1452 )];
1453 let out = codex_input_messages(messages);
1454 let item = &out[0];
1455 assert_eq!(
1456 item.get("type").and_then(Value::as_str),
1457 Some("function_call_output"),
1458 );
1459 assert_eq!(
1460 item.get("call_id").and_then(Value::as_str),
1461 Some("call_nZYquCuGUh8Qs9H51dwHMDgs"),
1462 );
1463 assert_eq!(item.get("output").and_then(Value::as_str), Some("total 0"));
1464 }
1465}
1466
1467#[cfg(test)]
1468mod codex_decoder_tests {
1469 use super::*;
1476 use crate::runtime::types::{LlmEvent, SessionEvent, StreamEvent};
1477
1478 fn collect_events(rx: &mut mpsc::UnboundedReceiver<StreamEvent>) -> Vec<StreamEvent> {
1479 let mut out = Vec::new();
1480 while let Ok(ev) = rx.try_recv() {
1481 out.push(ev);
1482 }
1483 out
1484 }
1485
1486 fn drive(lines: &[&str]) -> (CodexSseDecoder, String, Vec<StreamEvent>) {
1487 let (tx, mut rx) = mpsc::unbounded_channel();
1488 let mut decoder = CodexSseDecoder::default();
1489 let mut text_acc = String::new();
1490 for line in lines {
1491 decoder.push_line(line, &tx, &mut text_acc);
1492 }
1493 let events = collect_events(&mut rx);
1494 (decoder, text_acc, events)
1495 }
1496
1497 #[test]
1498 fn text_delta_aggregates_into_text_acc_and_emits_text_events() {
1499 let lines = [
1500 r#"data: {"type":"response.output_text.delta","delta":"Hello, "}"#,
1501 "",
1502 r#"data: {"type":"response.output_text.delta","delta":"world!"}"#,
1503 "",
1504 ];
1505 let (_decoder, text_acc, events) = drive(&lines);
1506 assert_eq!(text_acc, "Hello, world!");
1507 let texts: Vec<_> = events
1508 .iter()
1509 .filter_map(|e| match e {
1510 StreamEvent::Llm(LlmEvent::Text(t)) => Some(t.as_str()),
1511 _ => None,
1512 })
1513 .collect();
1514 assert_eq!(texts, vec!["Hello, ", "world!"]);
1515 }
1516
1517 #[test]
1518 fn single_function_call_completes_via_output_item_done() {
1519 let lines = [
1520 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_abc","name":"bash"}}"#,
1521 "",
1522 r#"data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"{\"cmd\""}"#,
1523 "",
1524 r#"data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":":\"ls\"}"}"#,
1525 "",
1526 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_abc","name":"bash","arguments":"{\"cmd\":\"ls\"}"}}"#,
1527 "",
1528 ];
1529 let (decoder, _text, events) = drive(&lines);
1530
1531 assert_eq!(decoder.completed_tools.len(), 1);
1532 let tool = &decoder.completed_tools[0];
1533 assert_eq!(tool.id, "call_abc");
1534 assert_eq!(tool.function.name, "bash");
1535 assert_eq!(tool.function.arguments, r#"{"cmd":"ls"}"#);
1536
1537 let starts: Vec<_> = events
1539 .iter()
1540 .filter_map(|e| match e {
1541 StreamEvent::Llm(LlmEvent::ToolUseStart { tool_name, tool_id }) => {
1542 Some((tool_name.as_str(), tool_id.as_str()))
1543 }
1544 _ => None,
1545 })
1546 .collect();
1547 assert_eq!(
1548 starts,
1549 vec![("bash", "call_abc")],
1550 "exactly one ToolUseStart with correct tool_id"
1551 );
1552
1553 let deltas: Vec<_> = events
1556 .iter()
1557 .filter_map(|e| match e {
1558 StreamEvent::Llm(LlmEvent::ToolUseDelta { tool_id, delta }) => {
1559 Some((tool_id.as_str(), delta.as_str()))
1560 }
1561 _ => None,
1562 })
1563 .collect();
1564 assert_eq!(
1565 deltas,
1566 vec![("call_abc", r#"{"cmd""#), ("call_abc", r#":"ls"}"#)]
1567 );
1568 }
1569
1570 #[test]
1571 fn parallel_tool_calls_indexed_separately() {
1572 let lines = [
1573 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"bash"}}"#,
1574 "",
1575 r#"data: {"type":"response.output_item.added","output_index":1,"item":{"type":"function_call","call_id":"call_2","name":"read"}}"#,
1576 "",
1577 r#"data: {"type":"response.function_call_arguments.delta","output_index":1,"delta":"{\"path\":\"a\"}"}"#,
1578 "",
1579 r#"data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"{\"cmd\":\"ls\"}"}"#,
1580 "",
1581 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"bash","arguments":"{\"cmd\":\"ls\"}"}}"#,
1582 "",
1583 r#"data: {"type":"response.output_item.done","output_index":1,"item":{"type":"function_call","call_id":"call_2","name":"read","arguments":"{\"path\":\"a\"}"}}"#,
1584 "",
1585 ];
1586 let (decoder, _text, _events) = drive(&lines);
1587
1588 assert_eq!(decoder.completed_tools.len(), 2);
1589 let mut by_id: std::collections::BTreeMap<&str, &ToolCall> =
1590 std::collections::BTreeMap::new();
1591 for tool in &decoder.completed_tools {
1592 by_id.insert(tool.id.as_str(), tool);
1593 }
1594 assert_eq!(by_id["call_1"].function.name, "bash");
1595 assert_eq!(by_id["call_1"].function.arguments, r#"{"cmd":"ls"}"#);
1596 assert_eq!(by_id["call_2"].function.name, "read");
1597 assert_eq!(by_id["call_2"].function.arguments, r#"{"path":"a"}"#);
1598 }
1599
1600 #[test]
1601 fn output_item_done_emits_tool_use_event() {
1602 let lines = [
1609 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_abc","name":"bash"}}"#,
1610 "",
1611 r#"data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"{\"command\":\"ls\"}"}"#,
1612 "",
1613 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_abc","name":"bash","arguments":"{\"command\":\"ls\"}"}}"#,
1614 "",
1615 ];
1616 let (_decoder, _text, events) = drive(&lines);
1617
1618 let tool_uses: Vec<_> = events
1619 .iter()
1620 .filter_map(|e| match e {
1621 StreamEvent::Llm(LlmEvent::ToolUse {
1622 tool_name,
1623 tool_id,
1624 input,
1625 }) => Some((tool_name.as_str(), tool_id.as_str(), input.clone())),
1626 _ => None,
1627 })
1628 .collect();
1629 assert_eq!(
1630 tool_uses.len(),
1631 1,
1632 "expected exactly one ToolUse finalize event"
1633 );
1634 assert_eq!(tool_uses[0].0, "bash");
1635 assert_eq!(tool_uses[0].1, "call_abc");
1636 assert_eq!(
1637 tool_uses[0].2,
1638 serde_json::json!({"command": "ls"}),
1639 "input must be parsed as a JSON Value, not a string"
1640 );
1641 }
1642
1643 #[test]
1644 fn parallel_tool_calls_emit_tool_use_per_index() {
1645 let lines = [
1649 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"bash"}}"#,
1650 "",
1651 r#"data: {"type":"response.output_item.added","output_index":1,"item":{"type":"function_call","call_id":"call_2","name":"read"}}"#,
1652 "",
1653 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"bash","arguments":"{\"command\":\"ls\"}"}}"#,
1654 "",
1655 r#"data: {"type":"response.output_item.done","output_index":1,"item":{"type":"function_call","call_id":"call_2","name":"read","arguments":"{\"path\":\"a\"}"}}"#,
1656 "",
1657 ];
1658 let (_decoder, _text, events) = drive(&lines);
1659
1660 let tool_uses: Vec<_> = events
1661 .iter()
1662 .filter_map(|e| match e {
1663 StreamEvent::Llm(LlmEvent::ToolUse {
1664 tool_name,
1665 tool_id,
1666 input,
1667 }) => Some((tool_name.clone(), tool_id.clone(), input.clone())),
1668 _ => None,
1669 })
1670 .collect();
1671
1672 assert_eq!(tool_uses.len(), 2, "one ToolUse finalize per parallel call");
1673 let by_id: std::collections::BTreeMap<&str, &(String, String, serde_json::Value)> =
1674 tool_uses.iter().map(|t| (t.1.as_str(), t)).collect();
1675 assert_eq!(by_id["call_1"].0, "bash");
1676 assert_eq!(by_id["call_1"].2, serde_json::json!({"command": "ls"}));
1677 assert_eq!(by_id["call_2"].0, "read");
1678 assert_eq!(by_id["call_2"].2, serde_json::json!({"path": "a"}));
1679 }
1680
1681 #[test]
1682 fn malformed_arguments_emit_tool_use_with_parse_error() {
1683 let lines = [
1688 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_bad","name":"bash"}}"#,
1689 "",
1690 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_bad","name":"bash","arguments":"{not json"}}"#,
1691 "",
1692 ];
1693 let (_decoder, _text, events) = drive(&lines);
1694
1695 let tool_use = events.iter().find_map(|e| match e {
1696 StreamEvent::Llm(LlmEvent::ToolUse { input, .. }) => Some(input.clone()),
1697 _ => None,
1698 });
1699 let input = tool_use.expect("ToolUse event missing");
1700 assert!(
1701 input.get("__parse_error").and_then(Value::as_str).is_some(),
1702 "malformed arguments must surface __parse_error, got {input}"
1703 );
1704 }
1705
1706 #[test]
1707 fn response_completed_emits_usage_event() {
1708 let lines = [
1709 r#"data: {"type":"response.completed","response":{"usage":{"input_tokens":42,"output_tokens":17}}}"#,
1710 "",
1711 ];
1712 let (_decoder, _text, events) = drive(&lines);
1713 let usage = events.iter().find_map(|e| match e {
1714 StreamEvent::Session(SessionEvent::Usage {
1715 input_tokens,
1716 output_tokens,
1717 ..
1718 }) => Some((*input_tokens, *output_tokens)),
1719 _ => None,
1720 });
1721 assert_eq!(usage, Some((42, 17)));
1722 }
1723
1724 #[test]
1730 fn response_completed_surfaces_cached_tokens_without_double_count() {
1731 let lines = [
1732 r#"data: {"type":"response.completed","response":{"usage":{"input_tokens":1000,"input_tokens_details":{"cached_tokens":900},"output_tokens":17}}}"#,
1733 "",
1734 ];
1735 let (_decoder, _text, events) = drive(&lines);
1736 let usage = events.iter().find_map(|e| match e {
1737 StreamEvent::Session(SessionEvent::Usage {
1738 input_tokens,
1739 output_tokens,
1740 cache_read_input_tokens,
1741 ..
1742 }) => Some((*input_tokens, *output_tokens, *cache_read_input_tokens)),
1743 _ => None,
1744 });
1745 assert_eq!(
1746 usage,
1747 Some((100, 17, 900)),
1748 "input must exclude the cached slice; cache_read must carry it"
1749 );
1750 }
1751
1752 #[test]
1753 fn response_completed_with_zero_usage_emits_nothing() {
1754 let lines = [
1755 r#"data: {"type":"response.output_text.delta","delta":"ok"}"#,
1756 "",
1757 r#"data: {"type":"response.completed","response":{"usage":{"input_tokens":0,"output_tokens":0}}}"#,
1758 "",
1759 ];
1760 let (decoder, _text, events) = drive(&lines);
1761 let any_usage = events
1762 .iter()
1763 .any(|e| matches!(e, StreamEvent::Session(SessionEvent::Usage { .. })));
1764 assert!(!any_usage, "zero-token usage should be suppressed");
1765 assert_eq!(decoder.terminal_result(), Ok(()));
1766 }
1767
1768 #[test]
1769 fn response_failed_is_a_terminal_failure_without_retaining_provider_text() {
1770 let lines = [
1771 r#"data: {"type":"response.failed","response":{"error":{"type":"server_error","message":"ECHOED:secret prompt"}}}"#,
1772 "",
1773 ];
1774 let (decoder, text, events) = drive(&lines);
1775 let failure = decoder.terminal_result().expect_err("must fail");
1776 assert_eq!(failure.code, "responses_failed");
1777 assert_eq!(
1778 failure.message,
1779 "Codex response failed in stream. Provider error details withheld because they can echo request content."
1780 );
1781 assert!(!failure.message.contains("ECHOED"));
1782 assert!(text.is_empty());
1783 assert!(events.is_empty());
1784 }
1785
1786 #[test]
1787 fn top_level_error_event_is_a_terminal_failure() {
1788 let lines = [
1789 r#"data: {"type":"error","error":{"type":"server_error","message":"private"}}"#,
1790 "",
1791 ];
1792 let (decoder, _text, _events) = drive(&lines);
1793 assert_eq!(
1794 decoder.terminal_result().expect_err("must fail").code,
1795 "responses_failed"
1796 );
1797 }
1798
1799 #[test]
1800 fn response_incomplete_is_not_misreported_as_empty_success() {
1801 let lines = [
1802 r#"data: {"type":"response.incomplete","response":{"incomplete_details":{"reason":"max_output_tokens"}}}"#,
1803 "",
1804 ];
1805 let (decoder, _text, _events) = drive(&lines);
1806 let failure = decoder.terminal_result().expect_err("must fail");
1807 assert_eq!(failure.code, "responses_incomplete");
1808 assert!(failure.message.contains("incomplete"));
1809 assert!(!failure.message.contains("max_output_tokens"));
1810 }
1811
1812 #[test]
1813 fn completed_without_text_or_tools_fails_at_transport_boundary() {
1814 let lines = [
1815 r#"data: {"type":"response.completed","response":{"usage":{"input_tokens":7,"output_tokens":0}}}"#,
1816 "",
1817 ];
1818 let (decoder, _text, _events) = drive(&lines);
1819 assert_eq!(
1820 decoder.terminal_result().expect_err("must fail").code,
1821 "responses_empty"
1822 );
1823 }
1824
1825 #[test]
1826 fn eof_without_terminal_event_fails_closed() {
1827 let lines = [
1828 r#"data: {"type":"response.created","response":{"id":"resp_123"}}"#,
1829 "",
1830 ];
1831 let (decoder, _text, _events) = drive(&lines);
1832 assert_eq!(
1833 decoder.terminal_result().expect_err("must fail").code,
1834 "responses_missing_terminal"
1835 );
1836 }
1837
1838 #[test]
1842 fn error_identifier_sanitizer_never_passes_freeform_text() {
1843 assert_eq!(sanitize_error_identifier(None), "absent");
1844 assert_eq!(
1845 sanitize_error_identifier(Some("rate_limit_exceeded")),
1846 "rate_limit_exceeded"
1847 );
1848 assert_eq!(
1849 sanitize_error_identifier(Some("context_length_exceeded")),
1850 "context_length_exceeded"
1851 );
1852 assert_eq!(
1853 sanitize_error_identifier(Some("some_new_code2")),
1854 "some_new_code2"
1855 );
1856 assert_eq!(
1858 sanitize_error_identifier(Some("sk-Abc123XYZ")),
1859 "unlisted_identifier"
1860 );
1861 assert_eq!(
1862 sanitize_error_identifier(Some("Server.Error")),
1863 "unlisted_identifier"
1864 );
1865 assert_eq!(
1866 sanitize_error_identifier(Some("ECHOED: user secret prompt")),
1867 "unsafe_or_freeform"
1868 );
1869 assert_eq!(sanitize_error_identifier(Some("")), "unsafe_or_freeform");
1870 let long = "a".repeat(65);
1871 assert_eq!(sanitize_error_identifier(Some(&long)), "unsafe_or_freeform");
1872 }
1873
1874 #[test]
1878 fn terminal_failure_user_message_is_specific_only_for_known_codes() {
1879 assert!(user_message_for_terminal_failure(
1880 "invalid_request_error",
1881 "context_length_exceeded"
1882 )
1883 .contains("/compact"));
1884 assert!(
1886 user_message_for_terminal_failure("context_length_exceeded", "absent")
1887 .contains("context window")
1888 );
1889 let default = user_message_for_terminal_failure("server_error", "unlisted_identifier");
1891 assert!(default.starts_with("Codex response failed in stream."));
1892 assert!(!default.contains("/compact"));
1893 }
1894
1895 #[test]
1896 fn done_marker_finishes_decoder() {
1897 let lines = [
1898 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_x","name":"bash"}}"#,
1899 "",
1900 r#"data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"{}"}"#,
1901 "",
1902 "data: [DONE]",
1903 "",
1904 ];
1905 let (decoder, _text, _events) = drive(&lines);
1906 assert_eq!(decoder.completed_tools.len(), 1);
1908 assert_eq!(decoder.completed_tools[0].id, "call_x");
1909 assert_eq!(decoder.completed_tools[0].function.arguments, "{}");
1910 }
1911
1912 #[test]
1913 fn finish_is_idempotent_no_double_emit() {
1914 let lines = [
1915 r#"data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_y","name":"bash","arguments":"{}"}}"#,
1916 "",
1917 ];
1918 let (mut decoder, _text, _events) = drive(&lines);
1919 assert_eq!(decoder.completed_tools.len(), 1);
1920
1921 decoder.finish();
1923 assert_eq!(
1924 decoder.completed_tools.len(),
1925 1,
1926 "finish() called twice must not double-emit"
1927 );
1928 }
1929
1930 #[test]
1931 fn finish_drains_active_tools_for_state_hygiene() {
1932 let lines = [
1937 r#"data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_z","name":"bash"}}"#,
1938 "",
1939 "data: [DONE]",
1940 "",
1941 ];
1942 let (decoder, _text, _events) = drive(&lines);
1943 assert_eq!(decoder.completed_tools.len(), 1);
1944 assert!(
1945 decoder.active_tools.is_empty(),
1946 "active_tools must be drained after finish()"
1947 );
1948 }
1949
1950 #[test]
1951 fn unknown_event_types_are_ignored() {
1952 let lines = [
1953 r#"data: {"type":"response.future_unknown_event","payload":{"x":1}}"#,
1954 "",
1955 r#"data: {"type":"response.output_text.delta","delta":"hi"}"#,
1956 "",
1957 ];
1958 let (_decoder, text_acc, _events) = drive(&lines);
1959 assert_eq!(text_acc, "hi");
1960 }
1961}
1962
1963#[cfg(test)]
1964mod broker_stream_tests {
1965 use super::*;
1971 use crate::auth::{CredentialBroker, LocalBroker};
1972 use std::sync::Arc;
1973
1974 async fn spawn_fake_openai_sse() -> (String, Arc<std::sync::Mutex<String>>) {
1975 use axum::routing::post;
1976 let seen_auth = Arc::new(std::sync::Mutex::new(String::new()));
1977 let seen = seen_auth.clone();
1978 let app = axum::Router::new().route(
1979 "/chat/completions",
1980 post(move |headers: axum::http::HeaderMap| {
1981 let seen = seen.clone();
1982 async move {
1983 *seen.lock().unwrap() = headers
1984 .get("authorization")
1985 .and_then(|v| v.to_str().ok())
1986 .unwrap_or("")
1987 .to_string();
1988 let body = concat!(
1989 "data: {\"choices\":[{\"delta\":{\"content\":\"Hel\"}}]}\n\n",
1990 "data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}\n\n",
1991 "data: [DONE]\n\n",
1992 );
1993 ([("content-type", "text/event-stream")], body)
1994 }
1995 }),
1996 );
1997 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1998 let addr = listener.local_addr().unwrap();
1999 tokio::spawn(async move {
2000 axum::serve(listener, app).await.unwrap();
2001 });
2002 (format!("http://{addr}"), seen_auth)
2003 }
2004
2005 #[tokio::test]
2006 async fn oai_stream_flows_through_broker_and_forwards_deltas() {
2007 let (upstream, seen_auth) = spawn_fake_openai_sse().await;
2008 let broker: Arc<dyn CredentialBroker> = Arc::new(LocalBroker::with_local_base_url(
2009 reqwest::Client::new(),
2010 upstream,
2011 ));
2012 let cfg = ProviderConfig {
2013 base_url: "unused-broker-derives-the-url".to_string(),
2014 model: "test-model".to_string(),
2015 provider: "local".to_string(),
2016 };
2017 let (tx, mut rx) = mpsc::unbounded_channel::<StreamEvent>();
2018 let cancel = tokio_util::sync::CancellationToken::new();
2019
2020 let result = call_oai_stream_inner(
2021 &cfg,
2022 &broker,
2023 &[],
2024 &None,
2025 &[],
2026 &tx,
2027 None,
2028 None,
2029 0,
2030 agent_core::reasoning::ReasoningLevel::Medium,
2031 &cancel,
2032 &crate::runtime::trace::TraceContext::disabled(),
2033 true,
2034 false,
2035 )
2036 .await
2037 .expect("stream must complete");
2038
2039 assert_eq!(result["content"][0]["text"], "Hello");
2041
2042 drop(tx);
2044 let mut streamed = String::new();
2045 while let Ok(ev) = rx.try_recv() {
2046 if let StreamEvent::Llm(crate::runtime::types::LlmEvent::Text(t)) = ev {
2047 streamed.push_str(&t);
2048 }
2049 }
2050 assert_eq!(streamed, "Hello");
2051
2052 assert_eq!(&*seen_auth.lock().unwrap(), "Bearer local");
2054 }
2055
2056 #[tokio::test]
2059 async fn suppressed_sync_route_does_not_enqueue_display_deltas() {
2060 let (upstream, _seen_auth) = spawn_fake_openai_sse().await;
2061 let broker: Arc<dyn CredentialBroker> = Arc::new(LocalBroker::with_local_base_url(
2062 reqwest::Client::new(),
2063 upstream,
2064 ));
2065 let cfg = ProviderConfig {
2066 base_url: "unused-broker-derives-the-url".to_string(),
2067 model: "test-model".to_string(),
2068 provider: "local".to_string(),
2069 };
2070 let (tx, mut rx) = mpsc::unbounded_channel::<StreamEvent>();
2071 let result = call_oai_stream_inner(
2072 &cfg,
2073 &broker,
2074 &[],
2075 &None,
2076 &[],
2077 &tx,
2078 None,
2079 None,
2080 0,
2081 agent_core::reasoning::ReasoningLevel::Medium,
2082 &tokio_util::sync::CancellationToken::new(),
2083 &crate::runtime::trace::TraceContext::disabled(),
2084 true,
2085 true,
2086 )
2087 .await
2088 .expect("stream must complete");
2089 assert_eq!(result["content"][0]["text"], "Hello");
2090 assert!(
2091 rx.try_recv().is_err(),
2092 "sync route must suppress display deltas at production"
2093 );
2094 }
2095
2096 #[tokio::test]
2097 async fn oai_stream_broker_error_fails_closed() {
2098 let broker: Arc<dyn CredentialBroker> = Arc::new(LocalBroker::new(reqwest::Client::new()));
2100 let cfg = ProviderConfig {
2101 base_url: String::new(),
2102 model: "m".to_string(),
2103 provider: "definitely-not-a-provider".to_string(),
2104 };
2105 let (tx, _rx) = mpsc::unbounded_channel::<StreamEvent>();
2106 let cancel = tokio_util::sync::CancellationToken::new();
2107 let err = call_oai_stream_inner(
2108 &cfg,
2109 &broker,
2110 &[],
2111 &None,
2112 &[],
2113 &tx,
2114 None,
2115 None,
2116 0,
2117 agent_core::reasoning::ReasoningLevel::Medium,
2118 &cancel,
2119 &crate::runtime::trace::TraceContext::disabled(),
2120 true,
2121 false,
2122 )
2123 .await
2124 .unwrap_err()
2125 .to_string();
2126 assert!(err.contains("unknown provider"), "got: {err}");
2127 assert!(!err.to_lowercase().contains("bearer"));
2128 }
2129
2130 #[tokio::test]
2135 async fn oai_stream_upstream_error_body_never_surfaces() {
2136 use axum::response::IntoResponse;
2137 const SENTINEL: &str = "PH1-OAI-BROKER-SENTINEL-91d2-RAW";
2138 let app = axum::Router::new().route(
2139 "/chat/completions",
2140 axum::routing::post(move |body: String| async move {
2141 (
2142 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
2143 [("content-type", "application/json")],
2144 format!("{{\"error\":{{\"message\":\"ECHOED:{body}\"}}}}"),
2145 )
2146 .into_response()
2147 }),
2148 );
2149 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2150 let addr = listener.local_addr().unwrap();
2151 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
2152
2153 let broker: Arc<dyn CredentialBroker> = Arc::new(LocalBroker::with_local_base_url(
2154 reqwest::Client::new(),
2155 format!("http://{addr}"),
2156 ));
2157 let cfg = ProviderConfig {
2158 base_url: "unused-broker-derives-the-url".to_string(),
2159 model: "test-model".to_string(),
2160 provider: "local".to_string(),
2161 };
2162 let tools = vec![serde_json::json!({
2163 "name": "ph1_secret_tool_zz",
2164 "description": "internal tool schema",
2165 "input_schema": {"type": "object", "properties": {}}
2166 })];
2167 let msgs: Vec<crate::SharedMessage> = vec![Arc::new(
2168 serde_json::json!({"role": "user", "content": SENTINEL}),
2169 )];
2170 let (tx, _rx) = mpsc::unbounded_channel::<StreamEvent>();
2171 let cancel = tokio_util::sync::CancellationToken::new();
2172
2173 let err = call_oai_stream_inner(
2174 &cfg,
2175 &broker,
2176 &tools,
2177 &Some(format!("system secret {SENTINEL}")),
2178 &msgs,
2179 &tx,
2180 None,
2181 None,
2182 0,
2183 agent_core::reasoning::ReasoningLevel::Medium,
2184 &cancel,
2185 &crate::runtime::trace::TraceContext::disabled(),
2186 true,
2187 false,
2188 )
2189 .await
2190 .expect_err("500 must fail")
2191 .to_string();
2192
2193 assert!(err.contains("500"), "status must survive: {err}");
2194 for banned in ["ECHOED", SENTINEL, "ph1_secret_tool_zz", "system secret"] {
2195 assert!(
2196 !err.contains(banned),
2197 "provider body content `{banned}` leaked into the surfaced error: {err}"
2198 );
2199 }
2200 }
2201}
2202
2203#[allow(clippy::too_many_arguments)]
2204pub(crate) async fn call_xai_responses_stream_inner(
2205 cfg: &ProviderConfig,
2206 broker: &std::sync::Arc<dyn crate::auth::CredentialBroker>,
2207 tools_schema: &[Value],
2208 system_prompt: &Option<String>,
2209 messages: &[crate::SharedMessage],
2210 tx: &mpsc::UnboundedSender<StreamEvent>,
2211 max_tokens: Option<u32>,
2212 reasoning_level: agent_core::reasoning::ReasoningLevel,
2213 cancel: &tokio_util::sync::CancellationToken,
2214 trace: &crate::runtime::trace::TraceContext,
2215 exact_wire_bytes: bool,
2216) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
2217 let (oai_tools, names) = translate::tools_to_oai(tools_schema);
2218 let input = codex_input_messages(translate::messages_to_oai(messages, system_prompt, &names));
2219 let tools: Vec<Value> = oai_tools
2220 .into_iter()
2221 .map(|tool| {
2222 json!({
2223 "type": "function",
2224 "name": tool.function.name,
2225 "description": tool.function.description.unwrap_or_default(),
2226 "parameters": tool.function.parameters,
2227 })
2228 })
2229 .collect();
2230 let body = build_xai_body(
2233 &cfg.model,
2234 reasoning_level,
2235 serde_json::to_value(input)?,
2236 tools,
2237 max_tokens,
2238 )
2239 .map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
2240 let (proxy_request, body_bytes) =
2246 crate::auth::ProxyRequest::post_json_exact("xai-auth", "/responses", body, true)?;
2247 let tracer = tr::begin_openai_tracer(
2248 trace,
2249 &cfg.provider,
2250 &cfg.model,
2251 if exact_wire_bytes {
2252 crate::runtime::trace::TransportKind::OpenAiResponses
2253 } else {
2254 crate::runtime::trace::TransportKind::CloudProxy
2255 },
2256 &format!("{}/responses", cfg.base_url.trim_end_matches('/')),
2257 exact_wire_bytes.then_some(body_bytes.as_ref()),
2258 messages,
2259 system_prompt.as_deref(),
2260 tools_schema,
2261 tr::renamed_tool_losses(&names),
2262 )
2263 .await;
2264 if let Some(tracer) = &tracer {
2270 trace.capture_request_content(tracer.request_id(), body_bytes.as_ref());
2271 }
2272 let mut attempt = tr::StreamAttempt::new(tracer);
2273 let stream = broker
2274 .proxy_stream(proxy_request)
2275 .await
2276 .map_err(|e| super::net::redact_provider_proxy_error(&e.to_string()));
2279 let mut stream = match stream {
2280 Ok(stream) => {
2281 attempt.mark_headers();
2282 stream
2283 }
2284 Err(msg) => {
2285 let status = tr::broker_error_status(&msg);
2286 let code = status.map_or_else(|| "broker_error".to_string(), |s| format!("http_{s}"));
2287 attempt.finish_failed(&code, status, None);
2288 return Err(msg.into());
2289 }
2290 };
2291 let mut text = String::new();
2292 let mut parser = CodexSseDecoder::default();
2293 let mut buf = bytes::BytesMut::new();
2294 while let Some(chunk) = tokio::select! {
2295 c = stream.next() => c,
2296 _ = cancel.cancelled() => {
2297 attempt.finish_canceled(None, parser.trace_usage());
2298 return Err("request canceled".into());
2299 }
2300 } {
2301 let chunk = match chunk {
2302 Ok(chunk) => {
2303 attempt.mark_first_byte();
2304 chunk
2305 }
2306 Err(e) => {
2307 attempt.finish_failed("stream_error", None, None);
2308 return Err(e.into());
2309 }
2310 };
2311 buf.extend_from_slice(&chunk);
2312 while let Some(n) = memchr::memchr(b'\n', &buf) {
2313 let line = buf.split_to(n + 1);
2314 parser.push_line(std::str::from_utf8(&line[..n]).unwrap_or(""), tx, &mut text);
2315 }
2316 if parser.saw_model_event {
2317 attempt.mark_first_model_event();
2318 }
2319 }
2320 if !buf.is_empty() {
2321 parser.push_line(std::str::from_utf8(&buf).unwrap_or(""), tx, &mut text);
2322 }
2323 parser.finish();
2324 if parser.saw_model_event {
2328 attempt.mark_first_model_event();
2329 }
2330 if let Err(failure) = parser.terminal_result() {
2334 attempt.finish_failed(failure.code, None, None);
2335 return Err(failure.message.into());
2336 }
2337 attempt.finish_success(None, None, None, parser.trace_usage());
2338 let mut content = Vec::new();
2339 if !text.is_empty() {
2340 content.push(json!({"type":"text","text":text}));
2341 }
2342 content.extend(translate::tool_calls_to_content_blocks(
2343 &parser.completed_tools,
2344 &names,
2345 ));
2346 Ok(json!({"role":"assistant","content":content}))
2347}
2348
2349pub(crate) fn build_xai_body(
2360 model: &str,
2361 level: agent_core::reasoning::ReasoningLevel,
2362 input: Value,
2363 tools: Vec<Value>,
2364 max_tokens: Option<u32>,
2365) -> Result<Value, String> {
2366 use agent_core::reasoning::ReasoningLevel;
2367 crate::runtime::openai::catalog::validation::validate_reasoning_mutation(
2368 &format!("xai-auth/{model}"),
2369 level,
2370 )?;
2371 let mut body = serde_json::Map::new();
2372 body.insert("model".into(), json!(model));
2373 body.insert("input".into(), input);
2374 body.insert("stream".into(), json!(true));
2375 if !tools.is_empty() {
2376 body.insert("tools".into(), Value::Array(tools));
2377 }
2378 if let Some(max) = max_tokens {
2379 body.insert("max_output_tokens".into(), json!(max));
2380 }
2381 match level {
2382 ReasoningLevel::Off | ReasoningLevel::Adaptive => {}
2385 l => {
2386 body.insert("reasoning".into(), json!({ "effort": l.as_str() }));
2387 }
2388 }
2389 Ok(Value::Object(body))
2390}
2391
2392#[cfg(test)]
2393mod xai_tests {
2394 use super::*;
2395 use agent_core::reasoning::ReasoningLevel;
2396
2397 #[test]
2398 fn xai_fixture_is_public_responses_shape() {
2399 let fixture = serde_json::json!({"type":"response.output_text.delta","delta":"hello"});
2400 assert_eq!(fixture["type"], "response.output_text.delta");
2401 assert_eq!(fixture["delta"], "hello");
2402 }
2403
2404 fn body_for(model: &str, level: ReasoningLevel) -> Result<Value, String> {
2405 build_xai_body(model, level, json!([]), Vec::new(), Some(1024))
2406 }
2407
2408 #[test]
2411 fn grok45_emits_exact_documented_efforts() {
2412 for (level, effort) in [
2413 (ReasoningLevel::Low, "low"),
2414 (ReasoningLevel::Medium, "medium"),
2415 (ReasoningLevel::High, "high"),
2416 ] {
2417 let body = body_for("grok-4.5", level).expect("supported effort");
2418 assert_eq!(body["reasoning"], json!({"effort": effort}), "{level}");
2419 assert_eq!(body["model"], "grok-4.5");
2420 assert_eq!(body["stream"], json!(true));
2421 assert_eq!(body["max_output_tokens"], json!(1024));
2422 }
2423 }
2424
2425 #[test]
2426 fn adaptive_omits_reasoning_field_provider_default() {
2427 for model in [
2428 "grok-4.5",
2429 "grok-4.5-latest",
2430 "grok-4.3",
2431 "grok-4.20-0309-non-reasoning",
2432 ] {
2433 let body = body_for(model, ReasoningLevel::Adaptive).expect("adaptive is omission");
2434 assert!(body.get("reasoning").is_none(), "{model}");
2435 }
2436 }
2437
2438 #[test]
2439 fn off_is_rejected_pre_network_when_reasoning_cannot_be_disabled() {
2440 for model in [
2441 "grok-4.5",
2442 "grok-4.5-latest",
2443 "grok-4.20-multi-agent-0309",
2444 "grok-4.3",
2445 ] {
2446 let err = body_for(model, ReasoningLevel::Off).unwrap_err();
2447 assert!(err.contains(model), "{model}: {err}");
2448 }
2449 let body = body_for("grok-4.20-0309-non-reasoning", ReasoningLevel::Off).unwrap();
2451 assert!(body.get("reasoning").is_none());
2452 }
2453
2454 #[test]
2455 fn unsupported_named_efforts_are_rejected_pre_network() {
2456 for level in [
2458 ReasoningLevel::XHigh,
2459 ReasoningLevel::Max,
2460 ReasoningLevel::Ultra,
2461 ] {
2462 assert!(body_for("grok-4.5", level).is_err(), "{level}");
2463 }
2464 assert!(body_for("grok-4.3", ReasoningLevel::Medium).is_err());
2466 assert!(body_for("grok-4.20-0309-non-reasoning", ReasoningLevel::Medium).is_err());
2468 assert!(body_for("grok-9000", ReasoningLevel::Medium).is_err());
2470 }
2471
2472 #[test]
2473 fn multi_agent_xhigh_is_exact_agent_count_control() {
2474 let body = body_for("grok-4.20-multi-agent-0309", ReasoningLevel::XHigh).unwrap();
2475 assert_eq!(body["reasoning"], json!({"effort": "xhigh"}));
2476 }
2477
2478 #[test]
2479 fn responses_wire_shape_tools_flat_and_no_chat_completions_fields() {
2480 let tools = vec![json!({
2481 "type": "function",
2482 "name": "get_weather",
2483 "description": "d",
2484 "parameters": {"type": "object"}
2485 })];
2486 let body =
2487 build_xai_body("grok-4.5", ReasoningLevel::High, json!([]), tools, None).unwrap();
2488 assert_eq!(body["tools"][0]["name"], "get_weather");
2491 assert!(body["tools"][0].get("function").is_none());
2492 assert!(body.get("input").is_some());
2493 assert!(body.get("messages").is_none());
2494 assert!(body.get("max_output_tokens").is_none());
2495 }
2496}
2497
2498#[cfg(test)]
2501mod codex_wire_tests {
2502 use crate::runtime::openai::catalog::{
2503 plan_codex_execution, CodexMultiAgentMode, CodexRequestRole,
2504 };
2505 use agent_core::reasoning::ReasoningLevel;
2506
2507 fn plan(
2508 level: ReasoningLevel,
2509 role: CodexRequestRole,
2510 ) -> crate::runtime::openai::catalog::CodexExecutionPlan {
2511 plan_codex_execution("openai-codex/gpt-5.6-sol", level, role, None)
2512 .expect("Sol request plan")
2513 }
2514
2515 #[allow(clippy::too_many_arguments)]
2516 fn body_for_level(
2517 model: &str,
2518 level: ReasoningLevel,
2519 input: serde_json::Value,
2520 instructions: String,
2521 tools: Vec<serde_json::Value>,
2522 temperature: Option<f32>,
2523 max_tokens: Option<u32>,
2524 ) -> serde_json::Value {
2525 let qualified = format!("openai-codex/{model}");
2526 let plan = plan_codex_execution(&qualified, level, CodexRequestRole::Foreground, None)
2527 .expect("request plan");
2528 super::build_codex_body(
2529 model,
2530 &plan,
2531 input,
2532 instructions,
2533 tools,
2534 temperature,
2535 max_tokens,
2536 "synaps-test-cache-key",
2537 )
2538 }
2539
2540 fn codex_effort_for(model_id: &str, level: ReasoningLevel) -> Result<String, String> {
2542 let qualified = format!("openai-codex/{model_id}");
2543 let plan = plan_codex_execution(&qualified, level, CodexRequestRole::Foreground, None)
2544 .map_err(|error| error.to_string())?;
2545 plan.wire_effort
2546 .map(|effort| effort.as_str().to_string())
2547 .ok_or_else(|| "reasoning effort omitted".to_string())
2548 }
2549
2550 #[test]
2551 fn sol_lowers_ultra_to_max_for_requests() {
2552 let effort = codex_effort_for("gpt-5.6-sol", ReasoningLevel::Ultra).unwrap();
2553 assert_eq!(effort, "max");
2554 }
2555
2556 #[test]
2557 fn sol_emits_max_exact() {
2558 let effort = codex_effort_for("gpt-5.6-sol", ReasoningLevel::Max).unwrap();
2559 assert_eq!(effort, "max");
2560 }
2561
2562 #[test]
2563 fn sol_emits_xhigh_exact() {
2564 let effort = codex_effort_for("gpt-5.6-sol", ReasoningLevel::XHigh).unwrap();
2565 assert_eq!(effort, "xhigh");
2566 }
2567
2568 #[test]
2569 fn luna_emits_max_exact() {
2570 let effort = codex_effort_for("gpt-5.6-luna", ReasoningLevel::Max).unwrap();
2571 assert_eq!(effort, "max");
2572 }
2573
2574 #[test]
2575 fn luna_rejects_ultra_before_network() {
2576 let err = codex_effort_for("gpt-5.6-luna", ReasoningLevel::Ultra).unwrap_err();
2578 assert!(err.contains("ultra"), "{err}");
2579 assert!(err.contains("gpt-5.6-luna"), "{err}");
2580 }
2581
2582 #[test]
2583 fn gpt55_emits_xhigh_exact() {
2584 let effort = codex_effort_for("gpt-5.5", ReasoningLevel::XHigh).unwrap();
2585 assert_eq!(effort, "xhigh");
2586 }
2587
2588 #[test]
2589 fn gpt55_rejects_max_before_network() {
2590 assert!(codex_effort_for("gpt-5.5", ReasoningLevel::Max).is_err());
2591 assert!(codex_effort_for("gpt-5.5", ReasoningLevel::Ultra).is_err());
2592 }
2593
2594 #[test]
2595 fn off_and_adaptive_omit_reasoning_field() {
2596 for level in [ReasoningLevel::Off, ReasoningLevel::Adaptive] {
2598 let body = body_for_level(
2599 "gpt-5.6-sol",
2600 level,
2601 serde_json::json!([]),
2602 "sys".to_string(),
2603 vec![],
2604 None,
2605 None,
2606 );
2607 assert!(
2608 body.get("reasoning").is_none(),
2609 "{level:?} must not emit reasoning field, body={body}"
2610 );
2611 }
2612 }
2613
2614 #[test]
2615 fn max_as_str_is_not_xhigh() {
2616 assert_ne!(ReasoningLevel::Max.as_str(), ReasoningLevel::XHigh.as_str());
2617 assert_eq!(ReasoningLevel::Max.as_str(), "max");
2618 }
2619
2620 #[test]
2621 fn ultra_mode_context_is_exact_proactive_developer_item() {
2622 let plan = plan(ReasoningLevel::Ultra, CodexRequestRole::Foreground);
2623 assert_eq!(plan.multi_agent_mode, Some(CodexMultiAgentMode::Proactive));
2624 let item = super::codex_multi_agent_mode_item(&plan).expect("Ultra context item");
2625 assert_eq!(
2626 item.get("type").and_then(serde_json::Value::as_str),
2627 Some("message")
2628 );
2629 assert_eq!(
2630 item.get("role").and_then(serde_json::Value::as_str),
2631 Some("developer")
2632 );
2633 assert_eq!(
2634 item.pointer("/content/0/text")
2635 .and_then(serde_json::Value::as_str),
2636 Some(concat!(
2637 "<multi_agent_mode>",
2638 "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.",
2639 "</multi_agent_mode>"
2640 ))
2641 );
2642 }
2643
2644 #[test]
2645 fn max_and_xhigh_mode_context_is_exact_explicit_only_item() {
2646 for level in [ReasoningLevel::Max, ReasoningLevel::XHigh] {
2647 let plan = plan(level, CodexRequestRole::Foreground);
2648 assert_eq!(
2649 plan.multi_agent_mode,
2650 Some(CodexMultiAgentMode::ExplicitRequestOnly)
2651 );
2652 let item = super::codex_multi_agent_mode_item(&plan).expect("v2 context item");
2653 assert_eq!(
2654 item.pointer("/content/0/text")
2655 .and_then(serde_json::Value::as_str),
2656 Some(concat!(
2657 "<multi_agent_mode>",
2658 "Do not spawn sub-agents unless the user or applicable AGENTS.md/skill instructions explicitly ask for sub-agents, delegation, or parallel agent work.",
2659 "</multi_agent_mode>"
2660 )),
2661 "{level:?}"
2662 );
2663 }
2664 }
2665
2666 #[test]
2667 fn mode_context_is_inserted_once_at_stable_head_position() {
2668 let plan = plan(ReasoningLevel::Ultra, CodexRequestRole::Foreground);
2669 let mut input = vec![
2670 serde_json::json!({"role":"user","content":"first"}),
2671 serde_json::json!({"role":"assistant","content":"answer"}),
2672 serde_json::json!({"role":"user","content":"current"}),
2673 ];
2674 super::insert_codex_multi_agent_mode(&mut input, &plan);
2675 let mode_positions = input
2676 .iter()
2677 .enumerate()
2678 .filter(|(_, item)| {
2679 item.pointer("/content/0/text")
2680 .and_then(serde_json::Value::as_str)
2681 .is_some_and(|text| text.starts_with("<multi_agent_mode>"))
2682 })
2683 .map(|(index, _)| index)
2684 .collect::<Vec<_>>();
2685 assert_eq!(mode_positions, vec![0]);
2686 assert_eq!(
2687 input[3].get("content").and_then(serde_json::Value::as_str),
2688 Some("current")
2689 );
2690 }
2691
2692 #[test]
2698 fn mode_context_placement_preserves_prompt_prefix_across_turns() {
2699 let plan = plan(ReasoningLevel::Ultra, CodexRequestRole::Foreground);
2700 let turn1 = vec![
2701 serde_json::json!({"role":"user","content":"first"}),
2702 serde_json::json!({"role":"assistant","content":"answer"}),
2703 ];
2704 let mut turn2 = turn1.clone();
2705 turn2.push(serde_json::json!({"role":"user","content":"follow-up"}));
2706 let mut input1 = turn1;
2707 let mut input2 = turn2;
2708 super::insert_codex_multi_agent_mode(&mut input1, &plan);
2709 super::insert_codex_multi_agent_mode(&mut input2, &plan);
2710 assert_eq!(
2711 &input2[..input1.len()],
2712 &input1[..],
2713 "turn N's input must be a strict prefix of turn N+1's input"
2714 );
2715 }
2716
2717 #[test]
2718 fn prompt_cache_key_is_stable_for_the_same_conversation_head() {
2719 let first = serde_json::json!({"role":"user","content":"hello"});
2720 let a = super::codex_prompt_cache_key("sys", Some(&first));
2721 let b = super::codex_prompt_cache_key("sys", Some(&first));
2722 assert_eq!(a, b);
2723 assert!(a.starts_with("synaps-"), "{a}");
2724 }
2725
2726 #[test]
2727 fn prompt_cache_key_differs_across_conversations() {
2728 let first = serde_json::json!({"role":"user","content":"hello"});
2729 let other = serde_json::json!({"role":"user","content":"different task"});
2730 assert_ne!(
2731 super::codex_prompt_cache_key("sys", Some(&first)),
2732 super::codex_prompt_cache_key("sys", Some(&other)),
2733 );
2734 assert_ne!(
2735 super::codex_prompt_cache_key("sys", Some(&first)),
2736 super::codex_prompt_cache_key("other-sys", Some(&first)),
2737 );
2738 }
2739
2740 #[test]
2741 fn body_carries_prompt_cache_key() {
2742 let body = body_for_level(
2743 "gpt-5.6-sol",
2744 ReasoningLevel::Medium,
2745 serde_json::json!([]),
2746 "sys".to_string(),
2747 vec![],
2748 None,
2749 None,
2750 );
2751 assert_eq!(
2752 body.get("prompt_cache_key")
2753 .and_then(serde_json::Value::as_str),
2754 Some("synaps-test-cache-key")
2755 );
2756 }
2757
2758 #[test]
2759 fn worker_ultra_has_no_mode_context_item() {
2760 let plan = plan(ReasoningLevel::Ultra, CodexRequestRole::Worker);
2761 assert!(super::codex_multi_agent_mode_item(&plan).is_none());
2762 }
2763
2764 #[test]
2767 fn body_lowers_ultra_reasoning_effort_to_max() {
2768 let body = body_for_level(
2769 "gpt-5.6-sol",
2770 ReasoningLevel::Ultra,
2771 serde_json::json!([]),
2772 "sys".to_string(),
2773 vec![],
2774 None,
2775 None,
2776 );
2777 let effort = body
2778 .get("reasoning")
2779 .and_then(|r| r.get("effort"))
2780 .and_then(serde_json::Value::as_str)
2781 .expect("reasoning.effort must be present for Ultra");
2782 assert_eq!(effort, "max");
2783 }
2784
2785 #[test]
2786 fn body_contains_reasoning_effort_for_max() {
2787 let body = body_for_level(
2788 "gpt-5.6-sol",
2789 ReasoningLevel::Max,
2790 serde_json::json!([]),
2791 "sys".to_string(),
2792 vec![],
2793 None,
2794 None,
2795 );
2796 let effort = body
2797 .get("reasoning")
2798 .and_then(|r| r.get("effort"))
2799 .and_then(serde_json::Value::as_str)
2800 .expect("reasoning.effort must be present for Max");
2801 assert_eq!(effort, "max");
2802 assert_ne!(effort, "xhigh");
2804 }
2805
2806 #[test]
2807 fn body_contains_reasoning_effort_for_xhigh() {
2808 let body = body_for_level(
2809 "gpt-5.6-sol",
2810 ReasoningLevel::XHigh,
2811 serde_json::json!([]),
2812 "sys".to_string(),
2813 vec![],
2814 None,
2815 None,
2816 );
2817 let effort = body
2818 .get("reasoning")
2819 .and_then(|r| r.get("effort"))
2820 .and_then(serde_json::Value::as_str)
2821 .expect("reasoning.effort must be present for XHigh");
2822 assert_eq!(effort, "xhigh");
2823 }
2824
2825 #[test]
2826 fn body_contains_reasoning_effort_for_low_medium_high() {
2827 for (level, expected) in [
2828 (ReasoningLevel::Low, "low"),
2829 (ReasoningLevel::Medium, "medium"),
2830 (ReasoningLevel::High, "high"),
2831 ] {
2832 let body = body_for_level(
2833 "gpt-5.6-sol",
2834 level,
2835 serde_json::json!([]),
2836 "sys".to_string(),
2837 vec![],
2838 None,
2839 None,
2840 );
2841 let effort = body
2842 .get("reasoning")
2843 .and_then(|r| r.get("effort"))
2844 .and_then(serde_json::Value::as_str)
2845 .unwrap_or_else(|| panic!("{level:?} must emit reasoning.effort"));
2846 assert_eq!(effort, expected, "{level:?}");
2847 }
2848 }
2849
2850 #[test]
2851 fn body_always_sets_model_and_stream_and_include() {
2852 let body = body_for_level(
2853 "gpt-5.5",
2854 ReasoningLevel::XHigh,
2855 serde_json::json!([]),
2856 "instructions".to_string(),
2857 vec![],
2858 None,
2859 None,
2860 );
2861 assert_eq!(
2862 body.get("model").and_then(serde_json::Value::as_str),
2863 Some("gpt-5.5")
2864 );
2865 assert_eq!(
2866 body.get("stream").and_then(serde_json::Value::as_bool),
2867 Some(true)
2868 );
2869 let includes = body.get("include").expect("include must be present");
2870 assert!(
2871 includes.as_array().map_or(false, |a| {
2872 a.iter()
2873 .any(|v| v.as_str() == Some("reasoning.encrypted_content"))
2874 }),
2875 "include must contain reasoning.encrypted_content"
2876 );
2877 assert_eq!(
2878 body.get("store").and_then(serde_json::Value::as_bool),
2879 Some(false)
2880 );
2881 }
2882
2883 #[test]
2884 fn body_omits_temperature_and_max_tokens_when_none() {
2885 let body = body_for_level(
2886 "gpt-5.6-sol",
2887 ReasoningLevel::Medium,
2888 serde_json::json!([]),
2889 "sys".to_string(),
2890 vec![],
2891 None,
2892 None,
2893 );
2894 assert!(body.get("temperature").is_none());
2895 assert!(body.get("max_output_tokens").is_none());
2896 }
2897
2898 #[test]
2899 fn body_includes_temperature_and_max_tokens_when_some() {
2900 let body = body_for_level(
2901 "gpt-5.6-sol",
2902 ReasoningLevel::High,
2903 serde_json::json!([]),
2904 "sys".to_string(),
2905 vec![],
2906 Some(0.7),
2907 Some(2048),
2908 );
2909 let temp = body
2910 .get("temperature")
2911 .and_then(serde_json::Value::as_f64)
2912 .unwrap();
2913 assert!((temp - 0.7f64).abs() < 0.01, "temperature={temp}");
2914 assert_eq!(
2915 body.get("max_output_tokens")
2916 .and_then(serde_json::Value::as_u64),
2917 Some(2048)
2918 );
2919 }
2920}
2921
2922#[cfg(test)]
2923mod send_retry_tests {
2924 use super::*;
2934 use agent_core::auth::{
2935 AccessToken, BrokerError, ProxyByteStream, ProxyRequest, ProxyResponse,
2936 };
2937 use agent_core::reasoning::ReasoningLevel;
2938 use async_trait::async_trait;
2939 use axum::{http::StatusCode, response::IntoResponse, routing::post as axum_post, Router};
2940 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
2941 use std::sync::{
2942 atomic::{AtomicUsize, Ordering},
2943 Arc,
2944 };
2945 use tokio::sync::mpsc;
2946
2947 const CODEX_SSE_SUCCESS: &str = concat!(
2949 "data: {\"type\":\"response.output_text.delta\",\"delta\":\"hello\"}\n\n",
2950 "data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n",
2951 "data: [DONE]\n\n",
2952 );
2953
2954 fn fake_codex_token() -> String {
2958 let payload = serde_json::json!({
2959 "https://api.openai.com/auth": { "chatgpt_account_id": "acct_test" }
2960 });
2961 format!("h.{}.s", URL_SAFE_NO_PAD.encode(payload.to_string()))
2962 }
2963
2964 struct TokenOnlyBroker;
2967
2968 #[async_trait]
2969 impl crate::auth::CredentialBroker for TokenOnlyBroker {
2970 async fn access_token(
2971 &self,
2972 _p: agent_core::auth::OAuthProviderId,
2973 ) -> Result<AccessToken, BrokerError> {
2974 Ok(AccessToken {
2975 token: fake_codex_token(),
2976 expires: u64::MAX,
2977 })
2978 }
2979 async fn proxy(&self, _request: ProxyRequest) -> Result<ProxyResponse, BrokerError> {
2980 Err(BrokerError::Denied("not implemented in stub".into()))
2981 }
2982 async fn proxy_stream(
2983 &self,
2984 _request: ProxyRequest,
2985 ) -> Result<ProxyByteStream, BrokerError> {
2986 Err(BrokerError::Denied("not implemented in stub".into()))
2987 }
2988 async fn anthropic_usage(&self) -> Result<serde_json::Value, BrokerError> {
2989 Err(BrokerError::Denied("not implemented in stub".into()))
2990 }
2991 async fn capabilities(&self) -> Result<Vec<agent_core::auth::ProviderStatus>, BrokerError> {
2992 Ok(vec![])
2993 }
2994 }
2995
2996 struct DenyingCountingBroker {
2997 access_calls: Arc<AtomicUsize>,
2998 }
2999
3000 #[async_trait]
3001 impl crate::auth::CredentialBroker for DenyingCountingBroker {
3002 async fn access_token(
3003 &self,
3004 _provider: agent_core::auth::OAuthProviderId,
3005 ) -> Result<AccessToken, BrokerError> {
3006 self.access_calls.fetch_add(1, Ordering::SeqCst);
3007 Err(BrokerError::Denied(
3008 "credential access must not occur".into(),
3009 ))
3010 }
3011
3012 async fn proxy(&self, _request: ProxyRequest) -> Result<ProxyResponse, BrokerError> {
3013 Err(BrokerError::Denied("not implemented in stub".into()))
3014 }
3015
3016 async fn proxy_stream(
3017 &self,
3018 _request: ProxyRequest,
3019 ) -> Result<ProxyByteStream, BrokerError> {
3020 Err(BrokerError::Denied("not implemented in stub".into()))
3021 }
3022
3023 async fn anthropic_usage(&self) -> Result<serde_json::Value, BrokerError> {
3024 Err(BrokerError::Denied("not implemented in stub".into()))
3025 }
3026
3027 async fn capabilities(&self) -> Result<Vec<agent_core::auth::ProviderStatus>, BrokerError> {
3028 Ok(vec![])
3029 }
3030 }
3031
3032 async fn spawn_mock_codex(
3035 fail_count: usize,
3036 fail_status: StatusCode,
3037 ) -> (String, Arc<AtomicUsize>) {
3038 let counter = Arc::new(AtomicUsize::new(0));
3039 let counter_clone = Arc::clone(&counter);
3040 let app = Router::new().route(
3041 "/codex/responses",
3042 axum_post(move || {
3043 let counter = Arc::clone(&counter_clone);
3044 async move {
3045 let n = counter.fetch_add(1, Ordering::SeqCst);
3046 if n < fail_count {
3047 (
3048 fail_status,
3049 [("content-type", "application/json")],
3050 "{\"error\":{\"message\":\"transient upstream sadness\"}}".to_string(),
3051 )
3052 .into_response()
3053 } else {
3054 (
3055 StatusCode::OK,
3056 [("content-type", "text/event-stream")],
3057 CODEX_SSE_SUCCESS.to_string(),
3058 )
3059 .into_response()
3060 }
3061 }
3062 }),
3063 );
3064 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3065 let addr = listener.local_addr().unwrap();
3066 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
3067 (format!("http://{addr}"), counter)
3068 }
3069
3070 async fn spawn_codex_sse(body: &'static str) -> String {
3071 let app = Router::new().route(
3072 "/codex/responses",
3073 axum_post(move || async move {
3074 (
3075 StatusCode::OK,
3076 [("content-type", "text/event-stream")],
3077 body,
3078 )
3079 }),
3080 );
3081 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3082 let addr = listener.local_addr().unwrap();
3083 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
3084 format!("http://{addr}")
3085 }
3086
3087 async fn spawn_empty_then_success_codex() -> (
3088 String,
3089 Arc<AtomicUsize>,
3090 Arc<std::sync::Mutex<Vec<bytes::Bytes>>>,
3091 ) {
3092 let counter = Arc::new(AtomicUsize::new(0));
3093 let bodies = Arc::new(std::sync::Mutex::new(Vec::new()));
3094 let app = Router::new().route(
3095 "/codex/responses",
3096 axum_post({
3097 let counter = Arc::clone(&counter);
3098 let bodies = Arc::clone(&bodies);
3099 move |body: bytes::Bytes| {
3100 let counter = Arc::clone(&counter);
3101 let bodies = Arc::clone(&bodies);
3102 async move {
3103 bodies.lock().unwrap().push(body);
3104 let n = counter.fetch_add(1, Ordering::SeqCst);
3105 let response = if n == 0 {
3106 concat!(
3107 "data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":7,\"output_tokens\":0}}}\n\n",
3108 "data: [DONE]\n\n",
3109 )
3110 } else {
3111 CODEX_SSE_SUCCESS
3112 };
3113 (
3114 StatusCode::OK,
3115 [("content-type", "text/event-stream")],
3116 response,
3117 )
3118 }
3119 }
3120 }),
3121 );
3122 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3123 let addr = listener.local_addr().unwrap();
3124 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
3125 (format!("http://{addr}"), counter, bodies)
3126 }
3127
3128 async fn run_codex(
3129 base_url: &str,
3130 max_retries: u32,
3131 ) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
3132 let cfg = ProviderConfig {
3133 base_url: base_url.to_string(),
3134 model: "gpt-5.6-sol".to_string(),
3136 provider: "openai-codex".to_string(),
3137 };
3138 let client = reqwest::Client::new();
3139 let broker: std::sync::Arc<dyn crate::auth::CredentialBroker> =
3140 std::sync::Arc::new(TokenOnlyBroker);
3141 let (tx, _rx) = mpsc::unbounded_channel();
3142 call_codex_stream_inner(
3143 &cfg,
3144 &client,
3145 &broker,
3146 &[],
3147 &Some("test".to_string()),
3148 &[],
3149 &tx,
3150 None,
3151 None,
3152 agent_core::reasoning::ReasoningLevel::Medium,
3153 crate::runtime::openai::catalog::CodexRequestRole::Foreground,
3154 &tokio_util::sync::CancellationToken::new(),
3155 max_retries,
3156 &crate::runtime::trace::TraceContext::disabled(),
3157 )
3158 .await
3159 }
3160
3161 #[tokio::test]
3162 async fn direct_ultra_tool_guard_precedes_broker_access() {
3163 let access_calls = Arc::new(AtomicUsize::new(0));
3164 let broker: Arc<dyn crate::auth::CredentialBroker> = Arc::new(DenyingCountingBroker {
3165 access_calls: Arc::clone(&access_calls),
3166 });
3167 let cfg = ProviderConfig {
3168 base_url: "http://127.0.0.1:1".to_string(),
3169 model: "gpt-5.6-sol".to_string(),
3170 provider: "openai-codex".to_string(),
3171 };
3172 let (tx, _rx) = mpsc::unbounded_channel();
3173
3174 let error = call_codex_stream_inner(
3175 &cfg,
3176 &reqwest::Client::new(),
3177 &broker,
3178 &[],
3179 &Some("test".to_string()),
3180 &[],
3181 &tx,
3182 None,
3183 None,
3184 ReasoningLevel::Ultra,
3185 crate::runtime::openai::catalog::CodexRequestRole::Foreground,
3186 &tokio_util::sync::CancellationToken::new(),
3187 0,
3188 &crate::runtime::trace::TraceContext::disabled(),
3189 )
3190 .await
3191 .expect_err("foreground Ultra without delegation tools must fail closed");
3192
3193 assert!(error.to_string().contains("subagent_start"), "{error}");
3194 assert_eq!(
3195 access_calls.load(Ordering::SeqCst),
3196 0,
3197 "preflight denial must happen before broker credential access"
3198 );
3199 }
3200
3201 #[tokio::test]
3202 async fn direct_codex_path_rejects_provider_identity_before_broker_access() {
3203 let access_calls = Arc::new(AtomicUsize::new(0));
3204 let broker: Arc<dyn crate::auth::CredentialBroker> = Arc::new(DenyingCountingBroker {
3205 access_calls: Arc::clone(&access_calls),
3206 });
3207 let cfg = ProviderConfig {
3208 base_url: "http://127.0.0.1:1".to_string(),
3209 model: "gpt-5.6-sol".to_string(),
3210 provider: "openrouter".to_string(),
3211 };
3212 let (tx, _rx) = mpsc::unbounded_channel();
3213
3214 let error = call_codex_stream_inner(
3215 &cfg,
3216 &reqwest::Client::new(),
3217 &broker,
3218 &[],
3219 &Some("test".to_string()),
3220 &[],
3221 &tx,
3222 None,
3223 None,
3224 ReasoningLevel::Medium,
3225 crate::runtime::openai::catalog::CodexRequestRole::Foreground,
3226 &tokio_util::sync::CancellationToken::new(),
3227 0,
3228 &crate::runtime::trace::TraceContext::disabled(),
3229 )
3230 .await
3231 .expect_err("the Codex seam must validate provider-qualified identity");
3232
3233 assert!(error.to_string().contains("openrouter"), "{error}");
3234 assert_eq!(
3235 access_calls.load(Ordering::SeqCst),
3236 0,
3237 "provider identity denial must precede broker credential access"
3238 );
3239 }
3240
3241 #[tokio::test]
3247 async fn codex_response_failed_event_is_a_terminal_error_not_empty_success() {
3248 let base_url = spawn_codex_sse(concat!(
3249 "data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"type\":\"server_error\",\"message\":\"ECHOED:secret prompt\"}}}\n\n",
3250 "data: [DONE]\n\n",
3251 ))
3252 .await;
3253 let err = run_codex(&base_url, 0)
3254 .await
3255 .expect_err("response.failed must fail the request");
3256 let msg = err.to_string();
3257 assert!(
3258 msg.starts_with("Codex response failed in stream."),
3259 "static failure message expected: {msg}"
3260 );
3261 assert!(
3262 !msg.contains("ECHOED") && !msg.contains("secret prompt"),
3263 "provider error body must never surface: {msg}"
3264 );
3265 }
3266
3267 #[tokio::test]
3270 async fn codex_response_incomplete_event_is_a_terminal_error() {
3271 let base_url = spawn_codex_sse(concat!(
3272 "data: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\n\n",
3273 "data: {\"type\":\"response.incomplete\",\"response\":{\"incomplete_details\":{\"reason\":\"max_output_tokens\"}}}\n\n",
3274 "data: [DONE]\n\n",
3275 ))
3276 .await;
3277 let err = run_codex(&base_url, 0)
3278 .await
3279 .expect_err("response.incomplete must fail the request");
3280 assert!(
3281 err.to_string()
3282 .starts_with("Codex response was incomplete."),
3283 "got: {err}"
3284 );
3285 }
3286
3287 #[tokio::test]
3290 async fn codex_eof_without_terminal_event_fails_closed() {
3291 let base_url = spawn_codex_sse(
3292 "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\"}}\n\n",
3293 )
3294 .await;
3295 let err = run_codex(&base_url, 0)
3296 .await
3297 .expect_err("EOF without terminal event must fail");
3298 assert!(
3299 err.to_string()
3300 .starts_with("Codex response stream ended without a terminal event."),
3301 "got: {err}"
3302 );
3303 }
3304
3305 #[tokio::test]
3306 async fn codex_completed_without_output_retries_identical_bytes_then_succeeds() {
3307 let (base_url, counter, bodies) = spawn_empty_then_success_codex().await;
3308 let result = run_codex(&base_url, 1)
3309 .await
3310 .expect("one empty-success stream with retries available must not abort the turn");
3311
3312 assert_eq!(counter.load(Ordering::SeqCst), 2, "expected one retry");
3313 let received = bodies.lock().unwrap();
3314 assert_eq!(received.len(), 2, "both attempts must reach the endpoint");
3315 assert_eq!(
3316 received[0], received[1],
3317 "the logical request must resend byte-identical content"
3318 );
3319 assert_eq!(result["content"][0]["text"], "hello");
3320 }
3321
3322 #[tokio::test]
3323 async fn codex_retries_transient_500_then_succeeds() {
3324 let (base_url, counter) = spawn_mock_codex(1, StatusCode::INTERNAL_SERVER_ERROR).await;
3325 let result = run_codex(&base_url, 2)
3326 .await
3327 .expect("one transient 500 with retries available must not abort the turn");
3328 assert_eq!(
3329 counter.load(Ordering::SeqCst),
3330 2,
3331 "expected exactly one retry"
3332 );
3333 let text = result["content"][0]["text"].as_str().unwrap_or_default();
3334 assert_eq!(text, "hello");
3335 }
3336
3337 #[tokio::test]
3338 async fn codex_response_failed_surfaces_typed_provider_error_not_empty_response() {
3339 const BODY: &str = concat!(
3340 "data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"type\":\"server_error\",\"message\":\"ECHOED:private prompt\"}}}\n\n",
3341 "data: [DONE]\n\n",
3342 );
3343 let base_url = spawn_codex_sse(BODY).await;
3344 let err = run_codex(&base_url, 0).await.expect_err("must fail");
3345 let msg = err.to_string();
3346 assert!(msg.starts_with("Codex response failed in stream."), "{msg}");
3347 assert!(!msg.contains("empty response"), "{msg}");
3348 assert!(!msg.contains("ECHOED"), "provider text leaked: {msg}");
3349 assert!(
3350 !msg.contains("private prompt"),
3351 "provider text leaked: {msg}"
3352 );
3353 }
3354
3355 #[tokio::test]
3356 async fn codex_response_incomplete_surfaces_typed_provider_error_not_empty_response() {
3357 const BODY: &str = concat!(
3358 "data: {\"type\":\"response.incomplete\",\"response\":{\"incomplete_details\":{\"reason\":\"max_output_tokens\"}}}\n\n",
3359 "data: [DONE]\n\n",
3360 );
3361 let base_url = spawn_codex_sse(BODY).await;
3362 let err = run_codex(&base_url, 0).await.expect_err("must fail");
3363 let msg = err.to_string();
3364 assert!(msg.starts_with("Codex response was incomplete."), "{msg}");
3365 assert!(!msg.contains("empty response"), "{msg}");
3366 assert!(
3367 !msg.contains("max_output_tokens"),
3368 "provider text leaked: {msg}"
3369 );
3370 }
3371
3372 #[tokio::test]
3373 async fn codex_clean_eof_without_terminal_fails_closed() {
3374 let base_url = spawn_codex_sse(
3375 "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_123\"}}\n\n",
3376 )
3377 .await;
3378 let err = run_codex(&base_url, 0).await.expect_err("must fail");
3379 assert!(
3380 err.to_string()
3381 .starts_with("Codex response stream ended without a terminal event."),
3382 "{err}"
3383 );
3384 }
3385
3386 #[tokio::test]
3387 async fn codex_retries_429_then_succeeds() {
3388 let (base_url, counter) = spawn_mock_codex(1, StatusCode::TOO_MANY_REQUESTS).await;
3389 run_codex(&base_url, 2)
3390 .await
3391 .expect("429 is transient — must retry");
3392 assert_eq!(counter.load(Ordering::SeqCst), 2);
3393 }
3394
3395 #[tokio::test]
3396 async fn codex_zero_retries_fails_fast_with_status_error() {
3397 let (base_url, counter) =
3398 spawn_mock_codex(usize::MAX, StatusCode::INTERNAL_SERVER_ERROR).await;
3399 let err = run_codex(&base_url, 0).await.expect_err("must fail");
3400 assert_eq!(counter.load(Ordering::SeqCst), 1, "no retries budgeted");
3401 let msg = err.to_string();
3402 assert!(
3403 msg.starts_with("codex request failed: 500"),
3404 "status must survive for classification: {msg}"
3405 );
3406 }
3407
3408 #[tokio::test]
3409 async fn codex_does_not_retry_client_errors() {
3410 let (base_url, counter) = spawn_mock_codex(usize::MAX, StatusCode::BAD_REQUEST).await;
3411 let err = run_codex(&base_url, 3).await.expect_err("must fail");
3412 assert_eq!(
3413 counter.load(Ordering::SeqCst),
3414 1,
3415 "400 is deterministic — retrying it never helps"
3416 );
3417 assert!(err.to_string().starts_with("codex request failed: 400"));
3418 }
3419
3420 #[tokio::test]
3421 async fn codex_exhausted_retries_reports_last_status() {
3422 let (base_url, counter) =
3423 spawn_mock_codex(usize::MAX, StatusCode::SERVICE_UNAVAILABLE).await;
3424 let err = run_codex(&base_url, 1).await.expect_err("must fail");
3425 assert_eq!(
3426 counter.load(Ordering::SeqCst),
3427 2,
3428 "initial attempt + 1 retry"
3429 );
3430 assert!(err.to_string().starts_with("codex request failed: 503"));
3431 }
3432
3433 #[test]
3437 fn codex_retry_budget_lifts_configured_default_to_persistent_floor() {
3438 assert_eq!(codex_retry_budget(3), CODEX_PERSISTENT_RETRIES);
3439 assert_eq!(codex_retry_budget(0), CODEX_PERSISTENT_RETRIES);
3440 assert_eq!(codex_retry_budget(10), 10);
3441 }
3442
3443 #[test]
3446 fn codex_retry_budget_honors_larger_user_configuration() {
3447 assert_eq!(codex_retry_budget(15), 15);
3448 }
3449
3450 #[test]
3453 fn retry_delay_is_exponential_with_a_capped_tail() {
3454 assert_eq!(retry_delay(1).as_secs(), 1);
3455 assert_eq!(retry_delay(2).as_secs(), 2);
3456 assert_eq!(retry_delay(4).as_secs(), 8);
3457 assert_eq!(retry_delay(7).as_secs(), 64);
3458 assert_eq!(retry_delay(10).as_secs(), 64);
3459 }
3460
3461 const ECHO_SENTINEL: &str = "PH1-OAI-SENTINEL-2b8c4d-RAW-CONTENT";
3469
3470 struct CaptureWriter(Arc<std::sync::Mutex<Vec<u8>>>);
3472
3473 impl std::io::Write for CaptureWriter {
3474 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
3475 self.0.lock().unwrap().extend_from_slice(buf);
3476 Ok(buf.len())
3477 }
3478 fn flush(&mut self) -> std::io::Result<()> {
3479 Ok(())
3480 }
3481 }
3482
3483 fn capture_subscriber(
3484 buf: &Arc<std::sync::Mutex<Vec<u8>>>,
3485 ) -> impl tracing::Subscriber + Send + Sync {
3486 let sink = buf.clone();
3487 tracing_subscriber::fmt()
3488 .with_max_level(tracing::Level::TRACE)
3489 .with_writer(move || CaptureWriter(sink.clone()))
3490 .with_ansi(false)
3491 .finish()
3492 }
3493
3494 async fn spawn_hostile_echo_provider(status: StatusCode) -> String {
3498 let app = Router::new().fallback(move |body: String| async move {
3499 let envelope = serde_json::json!({
3500 "error": { "message": format!("ECHOED:{body}") }
3501 });
3502 (
3503 status,
3504 [("content-type", "application/json")],
3505 envelope.to_string(),
3506 )
3507 .into_response()
3508 });
3509 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3510 let addr = listener.local_addr().unwrap();
3511 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
3512 format!("http://{addr}")
3513 }
3514
3515 fn sentinel_request_body() -> Value {
3519 json!({
3520 "model": "gpt-test",
3521 "messages": [
3522 {"role": "system", "content": format!("system secret {ECHO_SENTINEL}")},
3523 {"role": "user", "content": ECHO_SENTINEL},
3524 ],
3525 "tools": [{
3526 "type": "function",
3527 "function": {
3528 "name": "ph1_secret_tool_zz",
3529 "description": "internal tool schema",
3530 "parameters": {"type": "object", "properties": {}}
3531 }
3532 }],
3533 })
3534 }
3535
3536 const BANNED_MARKERS: &[&str] = &[
3538 "ECHOED",
3539 ECHO_SENTINEL,
3540 "ph1_secret_tool_zz",
3541 "\"messages\"",
3542 "system secret",
3543 ];
3544
3545 fn assert_no_banned(haystack: &str, context: &str) {
3546 for banned in BANNED_MARKERS {
3547 assert!(
3548 !haystack.contains(banned),
3549 "provider body content `{banned}` leaked into {context}: {haystack}"
3550 );
3551 }
3552 }
3553
3554 #[tokio::test]
3555 async fn send_with_retries_non_retryable_error_never_surfaces_provider_body() {
3556 let url = spawn_hostile_echo_provider(StatusCode::BAD_REQUEST).await;
3557 let client = reqwest::Client::new();
3558 let body = sentinel_request_body();
3559
3560 let buf = Arc::new(std::sync::Mutex::new(Vec::new()));
3561 let _guard = tracing::subscriber::set_default(capture_subscriber(&buf));
3562
3563 let err = send_with_retries(
3564 "codex",
3565 &url,
3566 || client.post(&url).json(&body),
3567 &tokio_util::sync::CancellationToken::new(),
3568 3,
3569 &mut tr::StreamAttempt::new(None),
3570 )
3571 .await
3572 .expect_err("400 must fail fast");
3573
3574 let msg = err.to_string();
3575 assert!(
3576 msg.starts_with("codex request failed: 400"),
3577 "status + label must survive for classification/usability: {msg}"
3578 );
3579 assert_no_banned(&msg, "the surfaced error");
3580 let logs = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
3581 assert_no_banned(&logs, "tracing output");
3582 }
3583
3584 #[tokio::test(start_paused = true)]
3585 async fn send_with_retries_retry_logs_never_contain_provider_body() {
3586 let url = spawn_hostile_echo_provider(StatusCode::SERVICE_UNAVAILABLE).await;
3587 let client = reqwest::Client::new();
3588 let body = sentinel_request_body();
3589
3590 let buf = Arc::new(std::sync::Mutex::new(Vec::new()));
3591 let _guard = tracing::subscriber::set_default(capture_subscriber(&buf));
3592
3593 let err = send_with_retries(
3594 "codex",
3595 &url,
3596 || client.post(&url).json(&body),
3597 &tokio_util::sync::CancellationToken::new(),
3598 1,
3599 &mut tr::StreamAttempt::new(None),
3600 )
3601 .await
3602 .expect_err("persistent 503 must exhaust the budget");
3603
3604 let msg = err.to_string();
3605 assert!(
3606 msg.starts_with("codex request failed: 503"),
3607 "status + label must survive after exhaustion: {msg}"
3608 );
3609 assert_no_banned(&msg, "the surfaced error");
3610
3611 let logs = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
3612 assert!(
3613 logs.contains("retry 1/1"),
3614 "retry accounting must stay observable: {logs}"
3615 );
3616 assert!(
3617 logs.contains("503"),
3618 "retry log must keep the status for diagnosis: {logs}"
3619 );
3620 assert_no_banned(&logs, "retry tracing output");
3621 }
3622}