Let typed worker budget own provider timeout

This commit is contained in:
Meta Vens 2026-08-22 14:46:02 +02:00
commit 08958b07b7
4 changed files with 50 additions and 3 deletions

View file

@ -233,19 +233,26 @@ impl EpiphanyCodexOpenAiTransport {
pub struct EpiphanyOpenRouterTransport {
api_key: String,
base_url: String,
request_timeout: Option<Duration>,
}
impl EpiphanyOpenRouterTransport {
pub fn new(api_key: impl Into<String>) -> Result<Self> {
pub fn new(api_key: impl Into<String>, request_timeout: Option<Duration>) -> Result<Self> {
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(

View file

@ -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<serde_json::Value> {
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(&current_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<u64>) -> Option<Duration> {
max_runtime_seconds
.is_none()
.then_some(DEFAULT_PROVIDER_REQUEST_TIMEOUT)
}
fn run_tool_adapter(
tool_adapter_bin: &PathBuf,
store_path: &PathBuf,

View file

@ -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<String>,
/// 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<Duration>,
}
#[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<Duration>,
}
#[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),
}
}

View file

@ -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)?;