diff --git a/epiphany-openai-codex-spine/src/lib.rs b/epiphany-openai-codex-spine/src/lib.rs index 770aa8fd..2be165c7 100644 --- a/epiphany-openai-codex-spine/src/lib.rs +++ b/epiphany-openai-codex-spine/src/lib.rs @@ -233,19 +233,26 @@ impl EpiphanyCodexOpenAiTransport { pub struct EpiphanyOpenRouterTransport { api_key: String, base_url: String, + request_timeout: Option, } impl EpiphanyOpenRouterTransport { - pub fn new(api_key: impl Into) -> Result { + pub fn new(api_key: impl Into, request_timeout: Option) -> Result { let api_key = api_key.into(); if api_key.trim().is_empty() || api_key.trim() != api_key { return Err(anyhow::anyhow!( "OpenRouter API key must be a non-empty trimmed credential" )); } + if request_timeout.is_some_and(|timeout| timeout.is_zero()) { + return Err(anyhow::anyhow!( + "OpenRouter request timeout must be positive when present" + )); + } Ok(Self { api_key, base_url: OPENROUTER_API_BASE_URL.to_string(), + request_timeout, }) } @@ -270,7 +277,7 @@ impl EpiphanyOpenRouterTransport { outbound .headers .insert(http::header::AUTHORIZATION, authorization); - outbound.timeout = Some(Duration::from_secs(90)); + outbound.timeout = self.request_timeout; let transport = ReqwestTransport::new(build_reqwest_client()); let response = transport .execute(outbound) @@ -1551,6 +1558,16 @@ mod tests { assert!(!status.codex_transport_attached); } + #[test] + fn openrouter_whole_request_timeout_is_explicit() -> Result<()> { + let bounded = EpiphanyOpenRouterTransport::new("test-key", Some(Duration::from_secs(90)))?; + assert_eq!(bounded.request_timeout, Some(Duration::from_secs(90))); + let outer_budget_owned = EpiphanyOpenRouterTransport::new("test-key", None)?; + assert_eq!(outer_budget_owned.request_timeout, None); + assert!(EpiphanyOpenRouterTransport::new("test-key", Some(Duration::ZERO)).is_err()); + Ok(()) + } + #[test] fn openrouter_terminal_tool_owns_typed_output_instead_of_response_format() { let mut request = openrouter_request( diff --git a/epiphany-openai-runtime/src/bin/epiphany-openai-runtime.rs b/epiphany-openai-runtime/src/bin/epiphany-openai-runtime.rs index 4c696e22..60c9afaf 100644 --- a/epiphany-openai-runtime/src/bin/epiphany-openai-runtime.rs +++ b/epiphany-openai-runtime/src/bin/epiphany-openai-runtime.rs @@ -21,6 +21,7 @@ use epiphany_openai_adapter::EpiphanyOpenAiModelReceipt; use epiphany_openai_adapter::EpiphanyOpenAiModelRequest; use epiphany_openai_adapter::EpiphanyOpenAiStreamEvent; use epiphany_openai_adapter::EpiphanyOpenAiStreamPayload; +use epiphany_openai_runtime::DEFAULT_PROVIDER_REQUEST_TIMEOUT; use epiphany_openai_runtime::EpiphanyOpenAiRuntimeOptions; use epiphany_openai_runtime::EpiphanyWorkerRuntimeOptions; use epiphany_openai_runtime::OPENAI_RUNTIME_ROLE; @@ -916,6 +917,7 @@ async fn run_worker_launch_with_tool_continuation( options: RunWorkerCliOptions, pass_progress: WorkerPassProgress, ) -> Result { + let request_timeout = provider_request_timeout(options.max_runtime_seconds); let tool_adapter_bin = options .tool_adapter_bin .clone() @@ -964,6 +966,7 @@ async fn run_worker_launch_with_tool_continuation( coordinator_note: "Native worker runtime route; Codex is auth/model transport only." .to_string(), default_model: Some(options.model.clone()), + request_timeout, }; let mut current_request_id = initial_request.request_id.clone(); pass_progress.note_model_request(¤t_request_id); @@ -1379,6 +1382,15 @@ mod tests { Ok(()) } + #[test] + fn typed_worker_budget_is_the_only_whole_request_deadline() { + assert_eq!( + provider_request_timeout(None), + Some(DEFAULT_PROVIDER_REQUEST_TIMEOUT) + ); + assert_eq!(provider_request_timeout(Some(600)), None); + } + #[test] fn model_turn_json_ingress_accepts_published_native_object() -> Result<()> { let request = parse_model_turn_request_json( @@ -1518,6 +1530,7 @@ mod tests { objective: format!("Run model execution for {worker_job_id}."), coordinator_note: "test".to_string(), default_model: Some("gpt-test".to_string()), + request_timeout: Some(DEFAULT_PROVIDER_REQUEST_TIMEOUT), }, &model_request, &[ @@ -2010,6 +2023,7 @@ async fn run_worker_options( if options.auto_tools { run_worker_launch_with_tool_continuation(options, pass_progress).await } else { + let request_timeout = provider_request_timeout(options.max_runtime_seconds); Ok(serde_json::to_value( run_worker_launch_observed( EpiphanyWorkerRuntimeOptions { @@ -2019,6 +2033,7 @@ async fn run_worker_options( provider: options.provider, job_id: options.job_id, model: options.model, + request_timeout, }, |request_id| pass_progress.note_model_request(request_id), ) @@ -2027,6 +2042,12 @@ async fn run_worker_options( } } +fn provider_request_timeout(max_runtime_seconds: Option) -> Option { + max_runtime_seconds + .is_none() + .then_some(DEFAULT_PROVIDER_REQUEST_TIMEOUT) +} + fn run_tool_adapter( tool_adapter_bin: &PathBuf, store_path: &PathBuf, diff --git a/epiphany-openai-runtime/src/lib.rs b/epiphany-openai-runtime/src/lib.rs index e628bd6b..37c09e44 100644 --- a/epiphany-openai-runtime/src/lib.rs +++ b/epiphany-openai-runtime/src/lib.rs @@ -1,6 +1,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; use std::path::PathBuf; +use std::time::Duration; use anyhow::Context; use anyhow::Result; @@ -61,6 +62,7 @@ mod persona_executor; pub use persona_executor::*; pub const OPENAI_RUNTIME_ROLE: &str = "openai-model-adapter"; +pub const DEFAULT_PROVIDER_REQUEST_TIMEOUT: Duration = Duration::from_secs(90); pub const OPENAI_RUNTIME_SOURCE: &str = "epiphany-openai-runtime"; pub const DEFAULT_MODEL_PROVIDER: &str = "openai-codex"; pub const OPENROUTER_MODEL_PROVIDER: &str = "openrouter"; @@ -101,6 +103,9 @@ pub struct EpiphanyOpenAiRuntimeOptions { pub objective: String, pub coordinator_note: String, pub default_model: Option, + /// Whole-request transport bound. Worker passes set this to `None` when + /// their outer typed runtime budget is the sole deadline owner. + pub request_timeout: Option, } #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] @@ -126,6 +131,7 @@ pub struct EpiphanyWorkerRuntimeOptions { pub provider: String, pub job_id: String, pub model: String, + pub request_timeout: Option, } #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] @@ -260,7 +266,7 @@ async fn run_openai_model_turn_bound( ), }, )?; - let transport = EpiphanyOpenRouterTransport::new(credential)?; + let transport = EpiphanyOpenRouterTransport::new(credential, options.request_timeout)?; let events = collect_transport_events( transport.collect_model_events_with_frame_observer( request.clone(), @@ -462,6 +468,7 @@ where coordinator_note: "Native worker runtime route; Codex is auth/model transport only." .to_string(), default_model: Some(options.model), + request_timeout: options.request_timeout, }; on_model_request(&model_request.request_id); let openai_summary = run_model_turn( @@ -1315,6 +1322,7 @@ pub fn default_options( coordinator_note: "Native OpenAI runtime route; Codex is auth/model transport only." .to_string(), default_model: Some(request.model.clone()), + request_timeout: Some(DEFAULT_PROVIDER_REQUEST_TIMEOUT), } } diff --git a/epiphany-openai-runtime/src/persona_executor.rs b/epiphany-openai-runtime/src/persona_executor.rs index abc531ab..8d007d3c 100644 --- a/epiphany-openai-runtime/src/persona_executor.rs +++ b/epiphany-openai-runtime/src/persona_executor.rs @@ -106,6 +106,7 @@ impl PersonaModelRunner for NativePersonaModelRunner { coordinator_note: "Native Persona model executor; transport owns inference only." .to_string(), default_model: Some(self.model.clone()), + request_timeout: Some(crate::DEFAULT_PROVIDER_REQUEST_TIMEOUT), }; let summary = run_model_turn(&self.provider, options.clone(), request.clone()).await?; let output = assistant_text_from_model_events(&self.store_path, &request.request_id)?;