Files
Aether/crates/aether-ai/serving/src/execution_path.rs
T

575 lines
18 KiB
Rust
Raw Normal View History

2026-05-02 13:23:54 +08:00
use async_trait::async_trait;
#[derive(Debug)]
pub enum AiServingExecutionOutcome<Response, Exhaustion> {
Responded(Response),
Deferred(Response),
2026-05-02 13:23:54 +08:00
Exhausted(Exhaustion),
NoPath,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AiPlanFallbackReason {
RemoteDecisionMiss,
SchedulerDecisionUnsupported,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AiSyncExecutionStep {
VideoTaskFollowUp,
LocalVideo,
LocalImage,
LocalOpenAiChat,
LocalOpenAiResponses,
LocalStandardFamily,
LocalSameFormatProvider,
LocalGeminiFiles,
RemoteDecision,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AiStreamExecutionStep {
LocalVideoContent,
LocalImage,
LocalOpenAiChat,
LocalOpenAiResponses,
LocalStandardFamily,
LocalSameFormatProvider,
LocalGeminiFiles,
RemoteDecision,
}
2026-06-25 22:36:27 +08:00
pub const DEFAULT_STREAM_EXECUTION_STEPS: &[AiStreamExecutionStep] = &[
AiStreamExecutionStep::LocalVideoContent,
AiStreamExecutionStep::LocalImage,
AiStreamExecutionStep::LocalOpenAiChat,
AiStreamExecutionStep::LocalOpenAiResponses,
AiStreamExecutionStep::LocalStandardFamily,
AiStreamExecutionStep::LocalSameFormatProvider,
AiStreamExecutionStep::LocalGeminiFiles,
AiStreamExecutionStep::RemoteDecision,
];
2026-05-02 13:23:54 +08:00
#[async_trait]
pub trait AiSyncExecutionPathPort: Send + Sync {
type Response: Send;
type Exhaustion: Send;
type Error: Send;
fn scheduler_decision_supported(&self) -> bool;
async fn execute_sync_step(
&self,
step: AiSyncExecutionStep,
) -> Result<AiServingExecutionOutcome<Self::Response, Self::Exhaustion>, Self::Error>;
async fn execute_sync_plan_fallback(
&self,
reason: AiPlanFallbackReason,
) -> Result<AiServingExecutionOutcome<Self::Response, Self::Exhaustion>, Self::Error>;
}
#[async_trait]
pub trait AiStreamExecutionPathPort: Send + Sync {
type Response: Send;
type Exhaustion: Send;
type Error: Send;
fn scheduler_decision_supported(&self) -> bool;
2026-06-25 22:36:27 +08:00
fn stream_execution_steps(&self) -> &'static [AiStreamExecutionStep] {
DEFAULT_STREAM_EXECUTION_STEPS
}
2026-05-02 13:23:54 +08:00
async fn execute_stream_step(
&self,
step: AiStreamExecutionStep,
) -> Result<AiServingExecutionOutcome<Self::Response, Self::Exhaustion>, Self::Error>;
async fn execute_stream_plan_fallback(
&self,
reason: AiPlanFallbackReason,
) -> Result<AiServingExecutionOutcome<Self::Response, Self::Exhaustion>, Self::Error>;
}
pub async fn run_ai_sync_execution_path<Port>(
port: &Port,
) -> Result<AiServingExecutionOutcome<Port::Response, Port::Exhaustion>, Port::Error>
where
Port: AiSyncExecutionPathPort,
{
let mut exhausted = None;
let mut deferred = None;
2026-05-02 13:23:54 +08:00
if let Some(response) = absorb_sync_step(
port,
AiSyncExecutionStep::VideoTaskFollowUp,
&mut deferred,
&mut exhausted,
)
.await?
2026-05-02 13:23:54 +08:00
{
return Ok(response);
}
if port.scheduler_decision_supported() {
for step in [
AiSyncExecutionStep::LocalVideo,
AiSyncExecutionStep::LocalImage,
AiSyncExecutionStep::LocalOpenAiChat,
AiSyncExecutionStep::LocalOpenAiResponses,
AiSyncExecutionStep::LocalStandardFamily,
AiSyncExecutionStep::LocalSameFormatProvider,
AiSyncExecutionStep::LocalGeminiFiles,
AiSyncExecutionStep::RemoteDecision,
] {
if let Some(response) =
absorb_sync_step(port, step, &mut deferred, &mut exhausted).await?
{
2026-05-02 13:23:54 +08:00
return Ok(response);
}
}
}
if let Some(response) = deferred {
return Ok(AiServingExecutionOutcome::Deferred(response));
}
2026-05-09 01:21:26 +08:00
if let Some(outcome) = exhausted {
return Ok(AiServingExecutionOutcome::Exhausted(outcome));
}
2026-05-02 13:23:54 +08:00
let fallback_reason = if port.scheduler_decision_supported() {
AiPlanFallbackReason::RemoteDecisionMiss
} else {
AiPlanFallbackReason::SchedulerDecisionUnsupported
};
match port.execute_sync_plan_fallback(fallback_reason).await? {
AiServingExecutionOutcome::Responded(response) => {
Ok(AiServingExecutionOutcome::Responded(response))
}
AiServingExecutionOutcome::Deferred(response) => {
Ok(AiServingExecutionOutcome::Deferred(response))
}
2026-05-02 13:23:54 +08:00
AiServingExecutionOutcome::Exhausted(outcome) => {
Ok(AiServingExecutionOutcome::Exhausted(outcome))
}
2026-05-09 01:21:26 +08:00
AiServingExecutionOutcome::NoPath => Ok(AiServingExecutionOutcome::NoPath),
2026-05-02 13:23:54 +08:00
}
}
pub async fn run_ai_stream_execution_path<Port>(
port: &Port,
) -> Result<AiServingExecutionOutcome<Port::Response, Port::Exhaustion>, Port::Error>
where
Port: AiStreamExecutionPathPort,
{
let mut exhausted = None;
let mut deferred = None;
2026-05-02 13:23:54 +08:00
2026-06-25 22:36:27 +08:00
for step in port.stream_execution_steps() {
if *step != AiStreamExecutionStep::LocalVideoContent && !port.scheduler_decision_supported()
{
continue;
}
if let Some(response) =
absorb_stream_step(port, *step, &mut deferred, &mut exhausted).await?
{
2026-06-25 22:36:27 +08:00
return Ok(response);
2026-05-02 13:23:54 +08:00
}
}
if let Some(response) = deferred {
return Ok(AiServingExecutionOutcome::Deferred(response));
}
2026-05-09 01:21:26 +08:00
if let Some(outcome) = exhausted {
return Ok(AiServingExecutionOutcome::Exhausted(outcome));
}
2026-05-02 13:23:54 +08:00
let fallback_reason = if port.scheduler_decision_supported() {
AiPlanFallbackReason::RemoteDecisionMiss
} else {
AiPlanFallbackReason::SchedulerDecisionUnsupported
};
match port.execute_stream_plan_fallback(fallback_reason).await? {
AiServingExecutionOutcome::Responded(response) => {
Ok(AiServingExecutionOutcome::Responded(response))
}
AiServingExecutionOutcome::Deferred(response) => {
Ok(AiServingExecutionOutcome::Deferred(response))
}
2026-05-02 13:23:54 +08:00
AiServingExecutionOutcome::Exhausted(outcome) => {
Ok(AiServingExecutionOutcome::Exhausted(outcome))
}
2026-05-09 01:21:26 +08:00
AiServingExecutionOutcome::NoPath => Ok(AiServingExecutionOutcome::NoPath),
2026-05-02 13:23:54 +08:00
}
}
async fn absorb_sync_step<Port>(
port: &Port,
step: AiSyncExecutionStep,
deferred: &mut Option<Port::Response>,
2026-05-02 13:23:54 +08:00
exhausted: &mut Option<Port::Exhaustion>,
) -> Result<Option<AiServingExecutionOutcome<Port::Response, Port::Exhaustion>>, Port::Error>
where
Port: AiSyncExecutionPathPort,
{
match port.execute_sync_step(step).await? {
AiServingExecutionOutcome::Responded(response) => {
Ok(Some(AiServingExecutionOutcome::Responded(response)))
}
AiServingExecutionOutcome::Deferred(response) => {
*deferred = Some(response);
Ok(None)
}
2026-05-02 13:23:54 +08:00
AiServingExecutionOutcome::Exhausted(outcome) => {
*exhausted = Some(outcome);
Ok(None)
}
AiServingExecutionOutcome::NoPath => Ok(None),
}
}
async fn absorb_stream_step<Port>(
port: &Port,
step: AiStreamExecutionStep,
deferred: &mut Option<Port::Response>,
2026-05-02 13:23:54 +08:00
exhausted: &mut Option<Port::Exhaustion>,
) -> Result<Option<AiServingExecutionOutcome<Port::Response, Port::Exhaustion>>, Port::Error>
where
Port: AiStreamExecutionPathPort,
{
match port.execute_stream_step(step).await? {
AiServingExecutionOutcome::Responded(response) => {
Ok(Some(AiServingExecutionOutcome::Responded(response)))
}
AiServingExecutionOutcome::Deferred(response) => {
*deferred = Some(response);
Ok(None)
}
2026-05-02 13:23:54 +08:00
AiServingExecutionOutcome::Exhausted(outcome) => {
*exhausted = Some(outcome);
Ok(None)
}
AiServingExecutionOutcome::NoPath => Ok(None),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::VecDeque;
use std::sync::Mutex;
#[derive(Default)]
struct TestSyncPort {
scheduler_supported: bool,
outcomes: Mutex<VecDeque<AiServingExecutionOutcome<&'static str, &'static str>>>,
calls: Mutex<Vec<String>>,
}
#[async_trait]
impl AiSyncExecutionPathPort for TestSyncPort {
type Response = &'static str;
type Exhaustion = &'static str;
type Error = std::convert::Infallible;
fn scheduler_decision_supported(&self) -> bool {
self.scheduler_supported
}
async fn execute_sync_step(
&self,
step: AiSyncExecutionStep,
) -> Result<AiServingExecutionOutcome<Self::Response, Self::Exhaustion>, Self::Error>
{
self.calls.lock().unwrap().push(format!("{step:?}"));
Ok(self
.outcomes
.lock()
.unwrap()
.pop_front()
.unwrap_or(AiServingExecutionOutcome::NoPath))
}
async fn execute_sync_plan_fallback(
&self,
reason: AiPlanFallbackReason,
) -> Result<AiServingExecutionOutcome<Self::Response, Self::Exhaustion>, Self::Error>
{
self.calls
.lock()
.unwrap()
.push(format!("Fallback:{reason:?}"));
Ok(self
.outcomes
.lock()
.unwrap()
.pop_front()
.unwrap_or(AiServingExecutionOutcome::NoPath))
}
}
#[derive(Default)]
struct TestStreamPort {
scheduler_supported: bool,
2026-06-25 22:36:27 +08:00
stream_steps: Option<&'static [AiStreamExecutionStep]>,
2026-05-02 13:23:54 +08:00
outcomes: Mutex<VecDeque<AiServingExecutionOutcome<&'static str, &'static str>>>,
calls: Mutex<Vec<String>>,
}
#[async_trait]
impl AiStreamExecutionPathPort for TestStreamPort {
type Response = &'static str;
type Exhaustion = &'static str;
type Error = std::convert::Infallible;
fn scheduler_decision_supported(&self) -> bool {
self.scheduler_supported
}
2026-06-25 22:36:27 +08:00
fn stream_execution_steps(&self) -> &'static [AiStreamExecutionStep] {
self.stream_steps
.unwrap_or(super::DEFAULT_STREAM_EXECUTION_STEPS)
}
2026-05-02 13:23:54 +08:00
async fn execute_stream_step(
&self,
step: AiStreamExecutionStep,
) -> Result<AiServingExecutionOutcome<Self::Response, Self::Exhaustion>, Self::Error>
{
self.calls.lock().unwrap().push(format!("{step:?}"));
Ok(self
.outcomes
.lock()
.unwrap()
.pop_front()
.unwrap_or(AiServingExecutionOutcome::NoPath))
}
async fn execute_stream_plan_fallback(
&self,
reason: AiPlanFallbackReason,
) -> Result<AiServingExecutionOutcome<Self::Response, Self::Exhaustion>, Self::Error>
{
self.calls
.lock()
.unwrap()
.push(format!("Fallback:{reason:?}"));
Ok(self
.outcomes
.lock()
.unwrap()
.pop_front()
.unwrap_or(AiServingExecutionOutcome::NoPath))
}
}
#[tokio::test]
async fn sync_path_runs_scheduler_steps_before_remote_and_fallback() {
let port = TestSyncPort {
scheduler_supported: true,
..TestSyncPort::default()
};
let outcome = run_ai_sync_execution_path(&port).await.unwrap();
assert!(matches!(outcome, AiServingExecutionOutcome::NoPath));
assert_eq!(
port.calls.lock().unwrap().as_slice(),
[
"VideoTaskFollowUp",
"LocalVideo",
"LocalImage",
"LocalOpenAiChat",
"LocalOpenAiResponses",
"LocalStandardFamily",
"LocalSameFormatProvider",
"LocalGeminiFiles",
"RemoteDecision",
"Fallback:RemoteDecisionMiss",
]
);
}
#[tokio::test]
async fn sync_path_returns_last_exhaustion_when_fallback_has_no_path() {
let port = TestSyncPort {
scheduler_supported: true,
outcomes: Mutex::new(VecDeque::from([
AiServingExecutionOutcome::NoPath,
AiServingExecutionOutcome::Exhausted("local_video_exhausted"),
])),
calls: Mutex::default(),
};
let outcome = run_ai_sync_execution_path(&port).await.unwrap();
assert!(matches!(
outcome,
AiServingExecutionOutcome::Exhausted("local_video_exhausted")
));
2026-05-09 01:21:26 +08:00
assert_eq!(
port.calls.lock().unwrap().as_slice(),
[
"VideoTaskFollowUp",
"LocalVideo",
"LocalImage",
"LocalOpenAiChat",
"LocalOpenAiResponses",
"LocalStandardFamily",
"LocalSameFormatProvider",
"LocalGeminiFiles",
"RemoteDecision",
]
);
2026-05-02 13:23:54 +08:00
}
#[tokio::test]
async fn sync_path_keeps_deferred_error_until_a_later_step_succeeds() {
let port = TestSyncPort {
scheduler_supported: true,
outcomes: Mutex::new(VecDeque::from([
AiServingExecutionOutcome::NoPath,
AiServingExecutionOutcome::Deferred("preserved_upstream_error"),
AiServingExecutionOutcome::Responded("later_success"),
])),
calls: Mutex::default(),
};
let outcome = run_ai_sync_execution_path(&port).await.unwrap();
assert!(matches!(
outcome,
AiServingExecutionOutcome::Responded("later_success")
));
assert_eq!(
port.calls.lock().unwrap().as_slice(),
["VideoTaskFollowUp", "LocalVideo", "LocalImage"]
);
}
2026-05-02 13:23:54 +08:00
#[tokio::test]
async fn stream_path_skips_scheduler_steps_when_unsupported() {
let port = TestStreamPort {
scheduler_supported: false,
..TestStreamPort::default()
};
let outcome = run_ai_stream_execution_path(&port).await.unwrap();
assert!(matches!(outcome, AiServingExecutionOutcome::NoPath));
assert_eq!(
port.calls.lock().unwrap().as_slice(),
["LocalVideoContent", "Fallback:SchedulerDecisionUnsupported",]
);
}
#[tokio::test]
async fn stream_path_stops_at_first_response() {
let port = TestStreamPort {
scheduler_supported: true,
2026-06-25 22:36:27 +08:00
stream_steps: None,
2026-05-02 13:23:54 +08:00
outcomes: Mutex::new(VecDeque::from([
AiServingExecutionOutcome::NoPath,
AiServingExecutionOutcome::Responded("image_response"),
])),
calls: Mutex::default(),
};
let outcome = run_ai_stream_execution_path(&port).await.unwrap();
assert!(matches!(
outcome,
AiServingExecutionOutcome::Responded("image_response")
));
assert_eq!(
port.calls.lock().unwrap().as_slice(),
["LocalVideoContent", "LocalImage"]
);
}
2026-05-09 01:21:26 +08:00
2026-06-25 22:36:27 +08:00
#[tokio::test]
async fn stream_path_runs_preferred_steps_only() {
const CHAT_ONLY: &[AiStreamExecutionStep] = &[AiStreamExecutionStep::LocalOpenAiChat];
let port = TestStreamPort {
scheduler_supported: true,
stream_steps: Some(CHAT_ONLY),
outcomes: Mutex::new(VecDeque::from([AiServingExecutionOutcome::Responded(
"chat_response",
)])),
calls: Mutex::default(),
};
let outcome = run_ai_stream_execution_path(&port).await.unwrap();
assert!(matches!(
outcome,
AiServingExecutionOutcome::Responded("chat_response")
));
assert_eq!(port.calls.lock().unwrap().as_slice(), ["LocalOpenAiChat"]);
}
#[tokio::test]
async fn stream_path_returns_deferred_error_only_after_steps_exhaust() {
const TWO_STEPS: &[AiStreamExecutionStep] = &[
AiStreamExecutionStep::LocalOpenAiChat,
AiStreamExecutionStep::LocalSameFormatProvider,
];
let port = TestStreamPort {
scheduler_supported: true,
stream_steps: Some(TWO_STEPS),
outcomes: Mutex::new(VecDeque::from([
AiServingExecutionOutcome::Deferred("preserved_upstream_error"),
AiServingExecutionOutcome::NoPath,
])),
calls: Mutex::default(),
};
let outcome = run_ai_stream_execution_path(&port).await.unwrap();
assert!(matches!(
outcome,
AiServingExecutionOutcome::Deferred("preserved_upstream_error")
));
assert_eq!(
port.calls.lock().unwrap().as_slice(),
["LocalOpenAiChat", "LocalSameFormatProvider"]
);
}
2026-05-09 01:21:26 +08:00
#[tokio::test]
async fn stream_path_returns_last_exhaustion_without_plan_fallback() {
let port = TestStreamPort {
scheduler_supported: true,
2026-06-25 22:36:27 +08:00
stream_steps: None,
2026-05-09 01:21:26 +08:00
outcomes: Mutex::new(VecDeque::from([
AiServingExecutionOutcome::NoPath,
AiServingExecutionOutcome::Exhausted("local_image_exhausted"),
])),
calls: Mutex::default(),
};
let outcome = run_ai_stream_execution_path(&port).await.unwrap();
assert!(matches!(
outcome,
AiServingExecutionOutcome::Exhausted("local_image_exhausted")
));
assert_eq!(
port.calls.lock().unwrap().as_slice(),
[
"LocalVideoContent",
"LocalImage",
"LocalOpenAiChat",
"LocalOpenAiResponses",
"LocalStandardFamily",
"LocalSameFormatProvider",
"LocalGeminiFiles",
"RemoteDecision",
]
);
}
2026-05-02 13:23:54 +08:00
}