refactor(workspace): enforce layered crate boundaries

This commit is contained in:
elky
2026-07-15 23:47:19 +08:00
parent a728c090a9
commit 8616fe6ee2
969 changed files with 40187 additions and 27240 deletions
@@ -0,0 +1,227 @@
use async_trait::async_trait;
pub trait AiExecutionAttempt {
fn execution_plan(&self) -> &aether_contracts::ExecutionPlan;
fn report_kind(&self) -> Option<String>;
fn report_context(&self) -> Option<serde_json::Value>;
}
#[derive(Debug)]
pub enum AiAttemptLoopOutcome<Response, Exhaustion> {
Responded(Response),
Exhausted(Exhaustion),
NoPath,
}
#[async_trait]
pub trait AiAttemptLoopPort<Attempt>: Send + Sync
where
Attempt: AiExecutionAttempt + Send + Sync + 'static,
{
type Response: Send;
type Exhaustion: Send;
type Error: Send;
async fn execute_attempt(
&self,
attempt: &Attempt,
) -> Result<Option<Self::Response>, Self::Error>;
async fn mark_unused_attempts(&self, attempts: Vec<Attempt>) -> Result<(), Self::Error>;
async fn build_exhaustion(
&self,
last_plan: aether_contracts::ExecutionPlan,
last_report_context: Option<serde_json::Value>,
) -> Result<Self::Exhaustion, Self::Error>;
}
pub async fn run_ai_attempt_loop<Port, Attempt>(
port: &Port,
attempts: Vec<Attempt>,
) -> Result<AiAttemptLoopOutcome<Port::Response, Port::Exhaustion>, Port::Error>
where
Port: AiAttemptLoopPort<Attempt>,
Attempt: AiExecutionAttempt + Send + Sync + 'static,
{
let mut remaining = attempts.into_iter();
let mut last_attempted = None;
while let Some(attempt) = remaining.next() {
last_attempted = Some((attempt.execution_plan().clone(), attempt.report_context()));
let response = match port.execute_attempt(&attempt).await {
Ok(response) => response,
Err(err) => {
port.mark_unused_attempts(remaining.collect()).await?;
return Err(err);
}
};
if let Some(response) = response {
port.mark_unused_attempts(remaining.collect()).await?;
return Ok(AiAttemptLoopOutcome::Responded(response));
}
}
let Some((last_plan, last_report_context)) = last_attempted else {
return Ok(AiAttemptLoopOutcome::NoPath);
};
Ok(AiAttemptLoopOutcome::Exhausted(
port.build_exhaustion(last_plan, last_report_context)
.await?,
))
}
impl AiExecutionAttempt for crate::dto::AiSyncAttempt {
fn execution_plan(&self) -> &aether_contracts::ExecutionPlan {
&self.plan
}
fn report_kind(&self) -> Option<String> {
self.report_kind.clone()
}
fn report_context(&self) -> Option<serde_json::Value> {
self.report_context.clone()
}
}
impl AiExecutionAttempt for crate::dto::AiStreamAttempt {
fn execution_plan(&self) -> &aether_contracts::ExecutionPlan {
&self.plan
}
fn report_kind(&self) -> Option<String> {
self.report_kind.clone()
}
fn report_context(&self) -> Option<serde_json::Value> {
self.report_context.clone()
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use std::sync::Mutex;
use async_trait::async_trait;
use super::{run_ai_attempt_loop, AiAttemptLoopPort, AiExecutionAttempt};
#[derive(Clone)]
struct TestAttempt {
id: &'static str,
plan: aether_contracts::ExecutionPlan,
}
impl AiExecutionAttempt for TestAttempt {
fn execution_plan(&self) -> &aether_contracts::ExecutionPlan {
&self.plan
}
fn report_kind(&self) -> Option<String> {
None
}
fn report_context(&self) -> Option<serde_json::Value> {
None
}
}
struct FailingPort {
fail_on: &'static str,
unused: Mutex<Vec<&'static str>>,
}
#[async_trait]
impl AiAttemptLoopPort<TestAttempt> for FailingPort {
type Response = ();
type Exhaustion = ();
type Error = &'static str;
async fn execute_attempt(
&self,
attempt: &TestAttempt,
) -> Result<Option<Self::Response>, Self::Error> {
if attempt.id == self.fail_on {
Err("attempt failed")
} else {
Ok(None)
}
}
async fn mark_unused_attempts(
&self,
attempts: Vec<TestAttempt>,
) -> Result<(), Self::Error> {
self.unused
.lock()
.expect("unused attempts should lock")
.extend(attempts.into_iter().map(|attempt| attempt.id));
Ok(())
}
async fn build_exhaustion(
&self,
_last_plan: aether_contracts::ExecutionPlan,
_last_report_context: Option<serde_json::Value>,
) -> Result<Self::Exhaustion, Self::Error> {
Ok(())
}
}
fn attempt(id: &'static str) -> TestAttempt {
TestAttempt {
id,
plan: aether_contracts::ExecutionPlan {
request_id: format!("request-{id}"),
candidate_id: Some(id.to_string()),
provider_name: Some("provider".to_string()),
provider_id: "provider-1".to_string(),
endpoint_id: "endpoint-1".to_string(),
key_id: "key-1".to_string(),
method: "POST".to_string(),
url: "https://example.test/v1/responses".to_string(),
headers: BTreeMap::new(),
content_type: Some("application/json".to_string()),
content_encoding: None,
body: aether_contracts::RequestBody::from_json(serde_json::json!({})),
stream: false,
client_api_format: "openai:responses".to_string(),
provider_api_format: "openai:responses".to_string(),
model_name: Some("gpt-5.6-sol".to_string()),
proxy: None,
transport_profile: None,
timeouts: None,
},
}
}
#[tokio::test]
async fn marks_unattempted_candidates_unused_when_execution_returns_error() {
let port = FailingPort {
fail_on: "candidate-2",
unused: Mutex::new(Vec::new()),
};
let error = run_ai_attempt_loop(
&port,
vec![
attempt("candidate-1"),
attempt("candidate-2"),
attempt("candidate-3"),
],
)
.await
.expect_err("second attempt should fail");
assert_eq!(error, "attempt failed");
assert_eq!(
*port.unused.lock().expect("unused attempts should lock"),
vec!["candidate-3"]
);
}
}
@@ -0,0 +1,859 @@
use std::collections::BTreeMap;
use aether_ai_formats::api::ExecutionRuntimeAuthContext;
use aether_contracts::{ExecutionPlan, RequestBody};
use url::Url;
use crate::dto::{AiExecutionDecision, AiRequestGzipPolicy};
const DEFAULT_REQUEST_GZIP_MIN_JSON_BYTES: usize = 64 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AiDecisionPlanCore {
pub request_id: String,
pub provider_id: String,
pub endpoint_id: String,
pub key_id: String,
pub provider_api_format: String,
pub client_api_format: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AiUpstreamAuthPair {
pub header: String,
pub value: String,
}
#[derive(Debug)]
pub struct AiExecutionPlanFromDecisionParts {
pub core: AiDecisionPlanCore,
pub method: String,
pub url: String,
pub headers: BTreeMap<String, String>,
pub content_type: Option<String>,
pub body: RequestBody,
pub stream: bool,
}
#[derive(Debug)]
pub struct AiExecutionDecisionFromPlanParts {
pub action: String,
pub decision_kind: Option<String>,
pub request_id: Option<String>,
pub upstream_base_url: Option<String>,
pub include_auth_pair: bool,
pub plan: ExecutionPlan,
pub report_kind: Option<String>,
pub report_context: Option<serde_json::Value>,
pub auth_context: Option<ExecutionRuntimeAuthContext>,
}
pub fn take_ai_non_empty_string(value: &mut Option<String>) -> Option<String> {
value.take().filter(|value| !value.trim().is_empty())
}
pub fn trim_ai_owned_non_empty_string(value: String) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return None;
}
if trimmed.len() == value.len() {
return Some(value);
}
Some(trimmed.to_owned())
}
pub fn take_ai_decision_plan_core(payload: &mut AiExecutionDecision) -> Option<AiDecisionPlanCore> {
Some(AiDecisionPlanCore {
request_id: take_ai_non_empty_string(&mut payload.request_id)?,
provider_id: take_ai_non_empty_string(&mut payload.provider_id)?,
endpoint_id: take_ai_non_empty_string(&mut payload.endpoint_id)?,
key_id: take_ai_non_empty_string(&mut payload.key_id)?,
provider_api_format: take_ai_non_empty_string(&mut payload.provider_api_format)?,
client_api_format: take_ai_non_empty_string(&mut payload.client_api_format)?,
})
}
pub fn take_ai_upstream_auth_pair(
payload: &mut AiExecutionDecision,
) -> Option<Option<AiUpstreamAuthPair>> {
let header = take_ai_non_empty_string(&mut payload.auth_header);
let value = take_ai_non_empty_string(&mut payload.auth_value);
match (header, value) {
(Some(header), Some(value)) => Some(Some(AiUpstreamAuthPair { header, value })),
(None, None) => Some(None),
_ => None,
}
}
pub fn resolve_ai_passthrough_sync_request_body(
provider_request_body: Option<serde_json::Value>,
provider_request_body_base64: Option<String>,
) -> RequestBody {
if let Some(body_bytes_b64) =
provider_request_body_base64.and_then(trim_ai_owned_non_empty_string)
{
return RequestBody {
json_body: None,
body_bytes_b64: Some(body_bytes_b64),
body_ref: None,
};
}
match provider_request_body.unwrap_or(serde_json::Value::Null) {
serde_json::Value::Null => RequestBody {
json_body: None,
body_bytes_b64: None,
body_ref: None,
},
other => RequestBody::from_json(other),
}
}
pub fn build_ai_execution_plan_from_decision(
payload: &mut AiExecutionDecision,
parts: AiExecutionPlanFromDecisionParts,
) -> ExecutionPlan {
let explicit_content_encoding = take_ai_non_empty_string(&mut payload.content_encoding);
let request_gzip = payload.request_gzip.take();
let content_encoding = explicit_content_encoding
.or_else(|| infer_ai_execution_plan_content_encoding(&parts, request_gzip.as_ref()));
ExecutionPlan {
request_id: parts.core.request_id,
candidate_id: payload.candidate_id.take(),
provider_name: payload.provider_name.take(),
provider_id: parts.core.provider_id,
endpoint_id: parts.core.endpoint_id,
key_id: parts.core.key_id,
method: parts.method,
url: parts.url,
headers: parts.headers,
content_type: parts.content_type,
content_encoding,
body: parts.body,
stream: parts.stream,
client_api_format: parts.core.client_api_format,
provider_api_format: parts.core.provider_api_format,
model_name: payload.model_name.take(),
proxy: payload.proxy.take(),
transport_profile: payload.transport_profile.take(),
timeouts: payload.timeouts.take(),
}
}
fn infer_ai_execution_plan_content_encoding(
parts: &AiExecutionPlanFromDecisionParts,
request_gzip: Option<&AiRequestGzipPolicy>,
) -> Option<String> {
if let Some(should_gzip) = should_gzip_explicit_json_request(parts, request_gzip) {
return should_gzip.then(|| "gzip".to_string());
}
None
}
fn should_gzip_explicit_json_request(
parts: &AiExecutionPlanFromDecisionParts,
request_gzip: Option<&AiRequestGzipPolicy>,
) -> Option<bool> {
let request_gzip = request_gzip?;
let enabled = request_gzip
.enabled
.unwrap_or(request_gzip.min_bytes.is_some());
if !enabled {
return Some(false);
}
Some(json_request_body_len_at_least(
parts,
request_gzip
.min_bytes
.unwrap_or(DEFAULT_REQUEST_GZIP_MIN_JSON_BYTES),
))
}
fn json_request_body_len_at_least(
parts: &AiExecutionPlanFromDecisionParts,
min_bytes: usize,
) -> bool {
if parts.body.body_bytes_b64.is_some() || parts.body.body_ref.is_some() {
return false;
}
let Some(json_body) = parts.body.json_body.as_ref() else {
return false;
};
serde_json::to_vec(json_body)
.map(|body| body.len() >= min_bytes)
.unwrap_or(false)
}
pub fn build_ai_execution_decision_from_plan(
parts: AiExecutionDecisionFromPlanParts,
) -> AiExecutionDecision {
let ExecutionPlan {
request_id,
candidate_id,
provider_name,
provider_id,
endpoint_id,
key_id,
method,
url,
headers,
content_type,
content_encoding,
body,
stream,
client_api_format,
provider_api_format,
model_name,
proxy,
transport_profile,
timeouts,
} = parts.plan;
let auth_pair = parts
.include_auth_pair
.then(|| extract_ai_auth_header_pair(&headers))
.flatten();
let provider_contract = provider_api_format.clone();
let client_contract = client_api_format.clone();
let request_id = parts.request_id.unwrap_or(request_id);
let auth_header = auth_pair.map(|(name, _)| name.to_string());
let auth_value = auth_pair.map(|(_, value)| value.to_string());
let RequestBody {
json_body,
body_bytes_b64,
body_ref: _body_ref,
} = body;
AiExecutionDecision {
action: parts.action,
decision_kind: parts.decision_kind,
execution_strategy: Some(ai_execution_strategy_for_formats(
provider_api_format.as_str(),
client_api_format.as_str(),
)),
conversion_mode: Some(ai_conversion_mode_for_formats(
provider_api_format.as_str(),
client_api_format.as_str(),
)),
request_id: Some(request_id),
candidate_id,
provider_name,
provider_type: None,
provider_id: Some(provider_id),
endpoint_id: Some(endpoint_id),
key_id: Some(key_id),
upstream_base_url: parts.upstream_base_url,
upstream_url: Some(url),
provider_request_method: Some(method),
auth_header,
auth_value,
provider_api_format: Some(provider_api_format),
client_api_format: Some(client_api_format),
provider_contract: Some(provider_contract),
client_contract: Some(client_contract),
model_name,
mapped_model: None,
prompt_cache_key: None,
extra_headers: BTreeMap::new(),
provider_request_headers: headers,
provider_request_body: json_body,
provider_request_body_base64: body_bytes_b64,
content_type,
content_encoding,
request_gzip: None,
proxy,
transport_profile,
timeouts,
upstream_is_stream: stream,
report_kind: parts.report_kind,
report_context: parts.report_context,
auth_context: parts.auth_context,
}
}
pub fn extract_ai_auth_header_pair(headers: &BTreeMap<String, String>) -> Option<(&str, &str)> {
[
"authorization",
"x-api-key",
"api-key",
"x-goog-api-key",
"proxy-authorization",
]
.into_iter()
.find_map(|name| {
headers
.iter()
.find(|(header_name, _)| header_name.eq_ignore_ascii_case(name))
.map(|(header_name, value)| (header_name.as_str(), value.as_str()))
})
}
pub fn infer_ai_upstream_base_url(upstream_url: &str) -> Option<String> {
let parsed = Url::parse(upstream_url).ok()?;
let host = parsed.host_str()?;
let mut base = format!("{}://{}", parsed.scheme(), host);
if let Some(port) = parsed.port() {
base.push(':');
base.push_str(port.to_string().as_str());
}
let base_path = infer_ai_upstream_base_path(parsed.path());
if !base_path.is_empty() {
base.push_str(base_path);
}
Some(base)
}
fn infer_ai_upstream_base_path(path: &str) -> &str {
let trimmed = path.trim_end_matches('/');
if trimmed.is_empty() || trimmed == "/" {
return "";
}
for suffix in [
"/responses/compact",
"/responses",
"/chat/completions",
"/messages",
] {
if let Some(prefix) = trimmed.strip_suffix(suffix) {
return normalize_inferred_ai_base_path(prefix);
}
}
for marker in ["/v1/videos", "/v1beta/"] {
if let Some((prefix, _)) = trimmed.split_once(marker) {
return normalize_inferred_ai_base_path(prefix);
}
}
normalize_inferred_ai_base_path(trimmed)
}
fn normalize_inferred_ai_base_path(path: &str) -> &str {
let trimmed = path.trim_end_matches('/');
if trimmed.is_empty() || trimmed == "/" {
""
} else {
trimmed
}
}
fn ai_execution_strategy_for_formats(provider_api_format: &str, client_api_format: &str) -> String {
if provider_api_format == client_api_format {
"local_same_format"
} else {
"local_cross_format"
}
.to_string()
}
fn ai_conversion_mode_for_formats(provider_api_format: &str, client_api_format: &str) -> String {
if provider_api_format == client_api_format {
"none"
} else {
"bidirectional"
}
.to_string()
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use serde_json::json;
use super::*;
#[test]
fn take_ai_decision_plan_core_consumes_required_non_empty_fields() {
let mut payload = test_decision();
let core =
take_ai_decision_plan_core(&mut payload).expect("core fields should be available");
assert_eq!(core.request_id, "req_1");
assert_eq!(core.provider_id, "provider_1");
assert_eq!(core.endpoint_id, "endpoint_1");
assert_eq!(core.key_id, "key_1");
assert_eq!(core.provider_api_format, "openai:chat");
assert_eq!(core.client_api_format, "openai:chat");
assert!(payload.request_id.is_none());
assert!(payload.provider_api_format.is_none());
}
#[test]
fn take_ai_decision_plan_core_rejects_blank_required_fields() {
let mut payload = test_decision();
payload.endpoint_id = Some(" ".to_string());
assert!(take_ai_decision_plan_core(&mut payload).is_none());
}
#[test]
fn take_ai_upstream_auth_pair_rejects_incomplete_auth() {
let mut payload = test_decision();
payload.auth_header = Some("authorization".to_string());
payload.auth_value = Some(" ".to_string());
assert!(take_ai_upstream_auth_pair(&mut payload).is_none());
}
#[test]
fn resolve_ai_passthrough_sync_request_body_prefers_trimmed_base64() {
let body = resolve_ai_passthrough_sync_request_body(
Some(json!({"ignored": true})),
Some(" YWJj ".to_string()),
);
assert_eq!(body.body_bytes_b64.as_deref(), Some("YWJj"));
assert!(body.json_body.is_none());
}
#[test]
fn resolve_ai_passthrough_sync_request_body_uses_json_when_no_base64() {
let body = resolve_ai_passthrough_sync_request_body(Some(json!({"ok": true})), None);
assert_eq!(body.json_body, Some(json!({"ok": true})));
assert!(body.body_bytes_b64.is_none());
}
#[test]
fn build_ai_execution_plan_from_decision_merges_core_and_remaining_payload_fields() {
let mut payload = test_decision();
let core =
take_ai_decision_plan_core(&mut payload).expect("core fields should be available");
let plan = build_ai_execution_plan_from_decision(
&mut payload,
AiExecutionPlanFromDecisionParts {
core,
method: "POST".to_string(),
url: "https://example.com/v1/chat/completions".to_string(),
headers: BTreeMap::from([(
"content-type".to_string(),
"application/json".to_string(),
)]),
content_type: Some("application/json".to_string()),
body: RequestBody::from_json(json!({"model": "gpt-test"})),
stream: true,
},
);
assert_eq!(plan.request_id, "req_1");
assert_eq!(plan.candidate_id.as_deref(), Some("candidate_1"));
assert_eq!(plan.provider_id, "provider_1");
assert_eq!(plan.endpoint_id, "endpoint_1");
assert_eq!(plan.key_id, "key_1");
assert!(plan.stream);
assert_eq!(plan.provider_api_format, "openai:chat");
assert_eq!(plan.client_api_format, "openai:chat");
assert_eq!(plan.model_name.as_deref(), Some("gpt-test"));
assert!(payload.candidate_id.is_none());
assert!(payload.model_name.is_none());
}
#[test]
fn build_ai_execution_plan_without_request_gzip_policy_leaves_json_uncompressed() {
let large_codex_url = test_plan_for_url_and_body(
"https://chatgpt.com/backend-api/codex/responses",
RequestBody::from_json(json!({
"model": "gpt-5.5",
"input": "x".repeat(DEFAULT_REQUEST_GZIP_MIN_JSON_BYTES),
})),
);
let large_openai = test_plan_for_url_and_body(
"https://api.openai.com/v1/responses",
RequestBody::from_json(json!({
"model": "gpt-5.5",
"input": "x".repeat(DEFAULT_REQUEST_GZIP_MIN_JSON_BYTES),
})),
);
assert_eq!(large_codex_url.content_encoding, None);
assert_eq!(large_openai.content_encoding, None);
}
#[test]
fn build_ai_execution_plan_does_not_gzip_raw_body_even_when_explicit() {
let mut payload = test_decision();
payload.request_gzip = Some(AiRequestGzipPolicy {
enabled: Some(true),
min_bytes: Some(0),
});
let core =
take_ai_decision_plan_core(&mut payload).expect("core fields should be available");
let plan = build_ai_execution_plan_from_decision(
&mut payload,
AiExecutionPlanFromDecisionParts {
core,
method: "POST".to_string(),
url: "https://api.example.com/v1/chat/completions".to_string(),
headers: BTreeMap::new(),
content_type: Some("application/json".to_string()),
body: RequestBody {
json_body: None,
body_bytes_b64: Some("dGVzdA==".to_string()),
body_ref: None,
},
stream: false,
},
);
assert_eq!(plan.content_encoding, None);
}
#[test]
fn build_ai_execution_plan_preserves_explicit_content_encoding_for_raw_body() {
let mut payload = test_decision();
payload.content_encoding = Some("gzip".to_string());
payload.request_gzip = Some(AiRequestGzipPolicy {
enabled: Some(false),
min_bytes: None,
});
let core =
take_ai_decision_plan_core(&mut payload).expect("core fields should be available");
let plan = build_ai_execution_plan_from_decision(
&mut payload,
AiExecutionPlanFromDecisionParts {
core,
method: "POST".to_string(),
url: "https://api.example.com/v1/chat/completions".to_string(),
headers: BTreeMap::new(),
content_type: Some("application/octet-stream".to_string()),
body: RequestBody {
json_body: None,
body_bytes_b64: Some("dGVzdA==".to_string()),
body_ref: None,
},
stream: false,
},
);
assert_eq!(plan.content_encoding.as_deref(), Some("gzip"));
}
#[test]
fn build_ai_execution_plan_gzips_explicit_json_request_for_non_codex() {
let mut payload = test_decision();
payload.request_gzip = Some(AiRequestGzipPolicy {
enabled: Some(true),
min_bytes: Some(1),
});
let core =
take_ai_decision_plan_core(&mut payload).expect("core fields should be available");
let plan = build_ai_execution_plan_from_decision(
&mut payload,
AiExecutionPlanFromDecisionParts {
core,
method: "POST".to_string(),
url: "https://api.example.com/v1/chat/completions".to_string(),
headers: BTreeMap::new(),
content_type: Some("application/json".to_string()),
body: RequestBody::from_json(json!({"model": "gpt-test"})),
stream: false,
},
);
assert_eq!(plan.content_encoding.as_deref(), Some("gzip"));
}
#[test]
fn build_ai_execution_plan_respects_explicit_request_gzip_threshold() {
let mut payload = test_decision();
payload.request_gzip = Some(AiRequestGzipPolicy {
enabled: Some(true),
min_bytes: Some(1024),
});
let core =
take_ai_decision_plan_core(&mut payload).expect("core fields should be available");
let plan = build_ai_execution_plan_from_decision(
&mut payload,
AiExecutionPlanFromDecisionParts {
core,
method: "POST".to_string(),
url: "https://api.example.com/v1/chat/completions".to_string(),
headers: BTreeMap::new(),
content_type: Some("application/json".to_string()),
body: RequestBody::from_json(json!({"model": "gpt-test"})),
stream: false,
},
);
assert_eq!(plan.content_encoding, None);
}
#[test]
fn build_ai_execution_plan_explicit_request_gzip_false_disables_gzip() {
let mut payload = test_decision();
payload.provider_api_format = Some("openai:responses".to_string());
payload.client_api_format = Some("openai:responses".to_string());
payload.request_gzip = Some(AiRequestGzipPolicy {
enabled: Some(false),
min_bytes: None,
});
let core =
take_ai_decision_plan_core(&mut payload).expect("core fields should be available");
let plan = build_ai_execution_plan_from_decision(
&mut payload,
AiExecutionPlanFromDecisionParts {
core,
method: "POST".to_string(),
url: "https://chatgpt.com/backend-api/codex/responses".to_string(),
headers: BTreeMap::new(),
content_type: Some("application/json".to_string()),
body: RequestBody::from_json(json!({
"model": "gpt-5.5",
"input": "x".repeat(DEFAULT_REQUEST_GZIP_MIN_JSON_BYTES),
})),
stream: true,
},
);
assert_eq!(plan.content_encoding, None);
}
#[test]
fn infer_ai_upstream_base_url_preserves_codex_base_path() {
assert_eq!(
infer_ai_upstream_base_url("https://tiger.bookapi.cc/codex/responses").as_deref(),
Some("https://tiger.bookapi.cc/codex")
);
assert_eq!(
infer_ai_upstream_base_url("https://chatgpt.com/backend-api/codex/responses")
.as_deref(),
Some("https://chatgpt.com/backend-api/codex")
);
}
#[test]
fn infer_ai_upstream_base_url_preserves_nested_v1_prefix() {
assert_eq!(
infer_ai_upstream_base_url(
"https://api.openai.example/custom/v1/chat/completions?mode=1"
)
.as_deref(),
Some("https://api.openai.example/custom/v1")
);
}
#[test]
fn infer_ai_upstream_base_url_strips_video_operation_path() {
assert_eq!(
infer_ai_upstream_base_url("https://video.example/nested/v1/videos/task-123/content")
.as_deref(),
Some("https://video.example/nested")
);
}
#[test]
fn build_ai_execution_decision_from_plan_maps_plan_fields() {
let plan = ExecutionPlan {
request_id: "plan-request".to_string(),
candidate_id: Some("candidate-1".to_string()),
provider_name: Some("provider".to_string()),
provider_id: "provider-1".to_string(),
endpoint_id: "endpoint-1".to_string(),
key_id: "key-1".to_string(),
method: "POST".to_string(),
url: "https://api.example.com/v1/chat/completions".to_string(),
headers: BTreeMap::from([("Authorization".to_string(), "Bearer secret".to_string())]),
content_type: Some("application/json".to_string()),
content_encoding: None,
body: RequestBody::from_json(json!({"model": "mapped"})),
stream: false,
client_api_format: "openai:chat".to_string(),
provider_api_format: "claude:messages".to_string(),
model_name: Some("mapped".to_string()),
proxy: None,
transport_profile: None,
timeouts: None,
};
let decision = build_ai_execution_decision_from_plan(AiExecutionDecisionFromPlanParts {
action: "execution_runtime.sync_decision".to_string(),
decision_kind: Some("openai_chat_sync".to_string()),
request_id: Some("trace-1".to_string()),
upstream_base_url: Some("https://api.example.com".to_string()),
include_auth_pair: true,
plan,
report_kind: Some("report".to_string()),
report_context: Some(json!({"candidate_index": 0})),
auth_context: None,
});
assert_eq!(decision.request_id.as_deref(), Some("trace-1"));
assert_eq!(
decision.execution_strategy.as_deref(),
Some("local_cross_format")
);
assert_eq!(decision.conversion_mode.as_deref(), Some("bidirectional"));
assert_eq!(decision.auth_header.as_deref(), Some("Authorization"));
assert_eq!(decision.auth_value.as_deref(), Some("Bearer secret"));
assert_eq!(
decision.provider_request_body,
Some(json!({"model": "mapped"}))
);
assert_eq!(decision.report_kind.as_deref(), Some("report"));
}
#[test]
fn plan_decision_round_trip_preserves_raw_body_content_encoding() {
let original = ExecutionPlan {
request_id: "plan-request".to_string(),
candidate_id: Some("candidate-1".to_string()),
provider_name: Some("provider".to_string()),
provider_id: "provider-1".to_string(),
endpoint_id: "endpoint-1".to_string(),
key_id: "key-1".to_string(),
method: "POST".to_string(),
url: "https://api.example.com/v1/upload".to_string(),
headers: BTreeMap::from([(
"content-type".to_string(),
"application/octet-stream".to_string(),
)]),
content_type: Some("application/octet-stream".to_string()),
content_encoding: Some("gzip".to_string()),
body: RequestBody {
json_body: None,
body_bytes_b64: Some("dGVzdA==".to_string()),
body_ref: None,
},
stream: false,
client_api_format: "openai:chat".to_string(),
provider_api_format: "openai:chat".to_string(),
model_name: Some("gpt-test".to_string()),
proxy: None,
transport_profile: None,
timeouts: None,
};
let mut decision =
build_ai_execution_decision_from_plan(AiExecutionDecisionFromPlanParts {
action: "execution_runtime.sync_decision".to_string(),
decision_kind: Some("raw_upload_sync".to_string()),
request_id: None,
upstream_base_url: Some("https://api.example.com".to_string()),
include_auth_pair: false,
plan: original,
report_kind: None,
report_context: None,
auth_context: None,
});
assert_eq!(decision.content_encoding.as_deref(), Some("gzip"));
assert!(decision.request_gzip.is_none());
let core =
take_ai_decision_plan_core(&mut decision).expect("core fields should be available");
let method = take_ai_non_empty_string(&mut decision.provider_request_method)
.expect("method should round-trip");
let url =
take_ai_non_empty_string(&mut decision.upstream_url).expect("url should round-trip");
let headers = std::mem::take(&mut decision.provider_request_headers);
let content_type = decision.content_type.take();
let body = resolve_ai_passthrough_sync_request_body(
decision.provider_request_body.take(),
decision.provider_request_body_base64.take(),
);
let stream = decision.upstream_is_stream;
let round_tripped = build_ai_execution_plan_from_decision(
&mut decision,
AiExecutionPlanFromDecisionParts {
core,
method,
url,
headers,
content_type,
body,
stream,
},
);
assert_eq!(round_tripped.content_encoding.as_deref(), Some("gzip"));
assert_eq!(
round_tripped.body.body_bytes_b64.as_deref(),
Some("dGVzdA==")
);
assert!(round_tripped.body.json_body.is_none());
}
fn test_decision() -> AiExecutionDecision {
AiExecutionDecision {
action: "sync".to_string(),
decision_kind: Some("test".to_string()),
execution_strategy: None,
conversion_mode: None,
request_id: Some("req_1".to_string()),
candidate_id: Some("candidate_1".to_string()),
provider_name: Some("provider".to_string()),
provider_type: None,
provider_id: Some("provider_1".to_string()),
endpoint_id: Some("endpoint_1".to_string()),
key_id: Some("key_1".to_string()),
upstream_base_url: Some("https://example.com".to_string()),
upstream_url: Some("https://example.com/v1/chat/completions".to_string()),
provider_request_method: None,
auth_header: Some("authorization".to_string()),
auth_value: Some("Bearer token".to_string()),
provider_api_format: Some("openai:chat".to_string()),
client_api_format: Some("openai:chat".to_string()),
provider_contract: Some("openai:chat".to_string()),
client_contract: Some("openai:chat".to_string()),
model_name: Some("gpt-test".to_string()),
mapped_model: Some("gpt-test".to_string()),
prompt_cache_key: None,
extra_headers: BTreeMap::new(),
provider_request_headers: BTreeMap::new(),
provider_request_body: None,
provider_request_body_base64: None,
content_type: None,
content_encoding: None,
request_gzip: None,
proxy: None,
transport_profile: None,
timeouts: None,
upstream_is_stream: false,
report_kind: None,
report_context: None,
auth_context: None,
}
}
fn test_plan_for_url_and_body(url: &str, body: RequestBody) -> ExecutionPlan {
test_plan_for_url_body_and_format(url, body, "openai:responses")
}
fn test_plan_for_url_body_and_format(
url: &str,
body: RequestBody,
provider_api_format: &str,
) -> ExecutionPlan {
let mut payload = test_decision();
payload.provider_api_format = Some(provider_api_format.to_string());
payload.client_api_format = Some(provider_api_format.to_string());
let core =
take_ai_decision_plan_core(&mut payload).expect("core fields should be available");
build_ai_execution_plan_from_decision(
&mut payload,
AiExecutionPlanFromDecisionParts {
core,
method: "POST".to_string(),
url: url.to_string(),
headers: BTreeMap::from([(
"content-type".to_string(),
"application/json".to_string(),
)]),
content_type: Some("application/json".to_string()),
body,
stream: true,
},
)
}
}
@@ -0,0 +1,159 @@
use async_trait::async_trait;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AiCandidateMaterializationOutcome<Attempt> {
pub attempts: Vec<Attempt>,
pub candidate_count: usize,
}
#[async_trait]
pub trait AiCandidateMaterializationPort: Send + Sync {
type Candidate: Send;
type Eligible: Send + Sync;
type Skipped: Send;
type Attempt: Send;
type Error: Send;
async fn resolve_and_rank_candidates(
&self,
candidates: Vec<Self::Candidate>,
) -> Result<(Vec<Self::Eligible>, Vec<Self::Skipped>), Self::Error>;
fn decorate_skipped_candidate(&self, skipped: Self::Skipped) -> Self::Skipped {
skipped
}
fn remember_first_candidate_affinity(&self, candidates: &[Self::Eligible]);
async fn persist_available_candidates(
&self,
candidates: Vec<Self::Eligible>,
) -> Result<Vec<Self::Attempt>, Self::Error>;
async fn persist_skipped_candidates(
&self,
starting_candidate_index: u32,
skipped_candidates: Vec<Self::Skipped>,
) -> Result<(), Self::Error>;
}
pub async fn run_ai_candidate_materialization<Port>(
port: &Port,
candidates: Vec<Port::Candidate>,
preselection_skipped_candidates: Vec<Port::Skipped>,
) -> Result<AiCandidateMaterializationOutcome<Port::Attempt>, Port::Error>
where
Port: AiCandidateMaterializationPort,
{
let (candidates, skipped_candidates) = port.resolve_and_rank_candidates(candidates).await?;
let skipped_candidates = preselection_skipped_candidates
.into_iter()
.chain(skipped_candidates)
.map(|candidate| port.decorate_skipped_candidate(candidate))
.collect::<Vec<_>>();
let candidate_count = candidates.len() + skipped_candidates.len();
port.remember_first_candidate_affinity(&candidates);
let available_candidate_count = candidates.len() as u32;
let attempts = port.persist_available_candidates(candidates).await?;
port.persist_skipped_candidates(available_candidate_count, skipped_candidates)
.await?;
Ok(AiCandidateMaterializationOutcome {
attempts,
candidate_count,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
#[derive(Default)]
struct TestPort {
calls: Mutex<Vec<String>>,
}
#[async_trait]
impl AiCandidateMaterializationPort for TestPort {
type Candidate = &'static str;
type Eligible = &'static str;
type Skipped = &'static str;
type Attempt = &'static str;
type Error = std::convert::Infallible;
async fn resolve_and_rank_candidates(
&self,
candidates: Vec<Self::Candidate>,
) -> Result<(Vec<Self::Eligible>, Vec<Self::Skipped>), Self::Error> {
self.calls
.lock()
.unwrap()
.push(format!("resolve:{}", candidates.join(",")));
Ok((vec!["eligible-a", "eligible-b"], vec!["resolved-skip"]))
}
fn decorate_skipped_candidate(&self, skipped: Self::Skipped) -> Self::Skipped {
self.calls
.lock()
.unwrap()
.push(format!("decorate:{skipped}"));
skipped
}
fn remember_first_candidate_affinity(&self, candidates: &[Self::Eligible]) {
self.calls
.lock()
.unwrap()
.push(format!("affinity:{}", candidates.join(",")));
}
async fn persist_available_candidates(
&self,
candidates: Vec<Self::Eligible>,
) -> Result<Vec<Self::Attempt>, Self::Error> {
self.calls
.lock()
.unwrap()
.push(format!("available:{}", candidates.join(",")));
Ok(vec!["attempt-a", "attempt-b"])
}
async fn persist_skipped_candidates(
&self,
starting_candidate_index: u32,
skipped_candidates: Vec<Self::Skipped>,
) -> Result<(), Self::Error> {
self.calls.lock().unwrap().push(format!(
"skipped:{starting_candidate_index}:{}",
skipped_candidates.join(",")
));
Ok(())
}
}
#[tokio::test]
async fn materialization_runs_in_serving_order_and_counts_all_candidates() {
let port = TestPort::default();
let outcome =
run_ai_candidate_materialization(&port, vec!["candidate-a"], vec!["pre-skip"])
.await
.unwrap();
assert_eq!(outcome.attempts, ["attempt-a", "attempt-b"]);
assert_eq!(outcome.candidate_count, 4);
assert_eq!(
port.calls.lock().unwrap().as_slice(),
[
"resolve:candidate-a",
"decorate:pre-skip",
"decorate:resolved-skip",
"affinity:eligible-a,eligible-b",
"available:eligible-a,eligible-b",
"skipped:2:pre-skip,resolved-skip",
]
);
}
}
@@ -0,0 +1,210 @@
use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
use serde_json::{Map, Value};
use crate::{ConversionMode, ExecutionStrategy};
pub struct AiCandidateMetadataParts<'a> {
pub provider_api_format: &'a str,
pub client_api_format: &'a str,
pub global_model_id: &'a str,
pub global_model_name: &'a str,
pub model_id: &'a str,
pub selected_provider_model_name: &'a str,
pub mapping_matched_model: Option<&'a str>,
pub provider_name: &'a str,
pub key_name: &'a str,
pub extra_fields: Map<String, Value>,
}
pub fn build_ai_candidate_metadata(parts: AiCandidateMetadataParts<'_>) -> Value {
let mut object = Map::new();
object.insert(
"provider_api_format".to_string(),
Value::String(parts.provider_api_format.to_string()),
);
object.insert(
"client_api_format".to_string(),
Value::String(parts.client_api_format.to_string()),
);
object.insert(
"global_model_id".to_string(),
Value::String(parts.global_model_id.to_string()),
);
object.insert(
"global_model_name".to_string(),
Value::String(parts.global_model_name.to_string()),
);
object.insert(
"model_id".to_string(),
Value::String(parts.model_id.to_string()),
);
object.insert(
"selected_provider_model_name".to_string(),
Value::String(parts.selected_provider_model_name.to_string()),
);
object.insert(
"mapping_matched_model".to_string(),
parts
.mapping_matched_model
.map(|value| Value::String(value.to_string()))
.unwrap_or(Value::Null),
);
object.insert(
"provider_name".to_string(),
Value::String(parts.provider_name.to_string()),
);
object.insert(
"key_name".to_string(),
Value::String(parts.key_name.to_string()),
);
object.extend(parts.extra_fields);
Value::Object(object)
}
pub fn build_ai_candidate_metadata_from_candidate(
candidate: &SchedulerMinimalCandidateSelectionCandidate,
provider_api_format: &str,
client_api_format: &str,
extra_fields: Map<String, Value>,
) -> Value {
build_ai_candidate_metadata(AiCandidateMetadataParts {
provider_api_format,
client_api_format,
global_model_id: candidate.global_model_id.as_str(),
global_model_name: candidate.global_model_name.as_str(),
model_id: candidate.model_id.as_str(),
selected_provider_model_name: candidate.selected_provider_model_name.as_str(),
mapping_matched_model: candidate.mapping_matched_model.as_deref(),
provider_name: candidate.provider_name.as_str(),
key_name: candidate.key_name.as_str(),
extra_fields,
})
}
pub fn append_ai_execution_contract_fields_to_value(
value: Value,
execution_strategy: &str,
conversion_mode: &str,
client_contract: &str,
provider_contract: &str,
) -> Value {
match value {
Value::Object(mut object) => {
object.insert(
"execution_strategy".to_string(),
Value::String(execution_strategy.to_string()),
);
object.insert(
"conversion_mode".to_string(),
Value::String(conversion_mode.to_string()),
);
object.insert(
"client_contract".to_string(),
Value::String(client_contract.to_string()),
);
object.insert(
"provider_contract".to_string(),
Value::String(provider_contract.to_string()),
);
Value::Object(object)
}
other => other,
}
}
pub fn ai_local_execution_contract_for_formats(
client_api_format: &str,
provider_api_format: &str,
) -> (ExecutionStrategy, ConversionMode) {
if aether_ai_formats::api_format_alias_matches(client_api_format, provider_api_format) {
return (ExecutionStrategy::LocalSameFormat, ConversionMode::None);
}
let conversion_mode =
if aether_ai_formats::request_conversion_kind(client_api_format, provider_api_format)
.is_some()
{
ConversionMode::Bidirectional
} else {
ConversionMode::None
};
(ExecutionStrategy::LocalCrossFormat, conversion_mode)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn candidate_metadata_builds_base_candidate_fields_and_extra_data() {
let mut extra_fields = Map::new();
extra_fields.insert("source".to_string(), json!("test"));
let metadata = build_ai_candidate_metadata(AiCandidateMetadataParts {
provider_api_format: "openai:responses",
client_api_format: "claude:messages",
global_model_id: "global-1",
global_model_name: "gpt-5.4",
model_id: "model-1",
selected_provider_model_name: "gpt-5.4",
mapping_matched_model: Some("gpt-5"),
provider_name: "RightCode",
key_name: "key-a",
extra_fields,
});
assert_eq!(metadata["provider_api_format"], "openai:responses");
assert_eq!(metadata["client_api_format"], "claude:messages");
assert_eq!(metadata["global_model_id"], "global-1");
assert_eq!(metadata["mapping_matched_model"], "gpt-5");
assert_eq!(metadata["source"], "test");
}
#[test]
fn execution_contract_fields_append_to_object_values_only() {
let value = append_ai_execution_contract_fields_to_value(
json!({"existing": true}),
"local_cross_format",
"bidirectional",
"openai:chat",
"claude:messages",
);
assert_eq!(value["existing"], true);
assert_eq!(value["execution_strategy"], "local_cross_format");
assert_eq!(value["conversion_mode"], "bidirectional");
assert_eq!(value["client_contract"], "openai:chat");
assert_eq!(value["provider_contract"], "claude:messages");
assert_eq!(
append_ai_execution_contract_fields_to_value(
Value::Null,
"local_same_format",
"none",
"openai:chat",
"openai:chat",
),
Value::Null
);
}
#[test]
fn local_execution_contract_is_derived_from_client_and_provider_formats() {
assert_eq!(
ai_local_execution_contract_for_formats(" OPENAI:CHAT ", "openai:chat"),
(ExecutionStrategy::LocalSameFormat, ConversionMode::None)
);
assert_eq!(
ai_local_execution_contract_for_formats("openai:chat", "claude:messages"),
(
ExecutionStrategy::LocalCrossFormat,
ConversionMode::Bidirectional,
)
);
assert_eq!(
ai_local_execution_contract_for_formats("openai:chat", "unknown:format"),
(ExecutionStrategy::LocalCrossFormat, ConversionMode::None)
);
}
}
@@ -0,0 +1,433 @@
use aether_scheduler_core::SchedulerRankingOutcome;
use async_trait::async_trait;
use serde_json::Value;
#[async_trait]
pub trait AiAvailableCandidatePersistencePort: Send + Sync {
type Candidate: Clone + Send + Sync;
type Attempt: Send;
type ExtraData: Clone + Send + Sync;
type Error: Send;
fn attempt_slot_count(&self, candidate: &Self::Candidate) -> u32;
fn build_extra_data(&self, candidate: &Self::Candidate) -> Option<Self::ExtraData>;
fn generate_candidate_id(&self) -> String;
fn should_persist_available_candidate(&self, candidate: &Self::Candidate) -> bool;
async fn persist_available_candidate(
&self,
candidate: &Self::Candidate,
candidate_index: u32,
retry_index: u32,
generated_candidate_id: &str,
extra_data: Option<Self::ExtraData>,
) -> Result<String, Self::Error>;
fn build_attempt(
&self,
candidate: Self::Candidate,
candidate_index: u32,
retry_index: u32,
candidate_id: String,
) -> Self::Attempt;
}
pub async fn run_ai_available_candidate_persistence<Port>(
port: &Port,
candidates: Vec<Port::Candidate>,
) -> Result<Vec<Port::Attempt>, Port::Error>
where
Port: AiAvailableCandidatePersistencePort,
{
let total_attempts = candidates
.iter()
.map(|candidate| port.attempt_slot_count(candidate) as usize)
.sum();
let mut materialized = Vec::with_capacity(total_attempts);
for (candidate_index, candidate) in candidates.into_iter().enumerate() {
let candidate_index = candidate_index as u32;
let attempt_slots = port.attempt_slot_count(&candidate).max(1);
let extra_data = port.build_extra_data(&candidate);
let mut owned_candidate = Some(candidate);
for retry_index in 0..attempt_slots {
let candidate = owned_candidate
.as_ref()
.expect("candidate should remain available until final retry");
let generated_candidate_id = port.generate_candidate_id();
let candidate_id = if port.should_persist_available_candidate(candidate) {
port.persist_available_candidate(
candidate,
candidate_index,
retry_index,
generated_candidate_id.as_str(),
extra_data.clone(),
)
.await?
} else {
generated_candidate_id
};
let candidate = if retry_index + 1 == attempt_slots {
owned_candidate
.take()
.expect("final retry should consume owned candidate")
} else {
candidate.clone()
};
materialized.push(port.build_attempt(
candidate,
candidate_index,
retry_index,
candidate_id,
));
}
}
Ok(materialized)
}
#[async_trait]
pub trait AiSkippedCandidatePersistencePort: Send + Sync {
type Skipped: Send + Sync;
type ExtraData: Send + Sync;
type Error: Send;
fn should_persist_skipped_candidate(&self, candidate: &Self::Skipped) -> bool;
fn build_extra_data(&self, candidate: &Self::Skipped) -> Option<Self::ExtraData>;
fn generate_candidate_id(&self) -> String;
async fn persist_skipped_candidate(
&self,
candidate: &Self::Skipped,
candidate_index: u32,
generated_candidate_id: &str,
extra_data: Option<Self::ExtraData>,
) -> Result<(), Self::Error>;
}
pub async fn run_ai_skipped_candidate_persistence<Port>(
port: &Port,
starting_candidate_index: u32,
skipped_candidates: Vec<Port::Skipped>,
) -> Result<(), Port::Error>
where
Port: AiSkippedCandidatePersistencePort,
{
let mut next_candidate_index = starting_candidate_index;
for skipped_candidate in skipped_candidates {
if !port.should_persist_skipped_candidate(&skipped_candidate) {
continue;
}
let generated_candidate_id = port.generate_candidate_id();
let extra_data = port.build_extra_data(&skipped_candidate);
port.persist_skipped_candidate(
&skipped_candidate,
next_candidate_index,
generated_candidate_id.as_str(),
extra_data,
)
.await?;
next_candidate_index = next_candidate_index.saturating_add(1);
}
Ok(())
}
pub fn ai_should_persist_available_candidate_for_pool_key(pool_key_index: Option<u32>) -> bool {
pool_key_index.is_none()
}
pub fn ai_should_persist_skipped_candidate_for_pool_membership(is_pool_candidate: bool) -> bool {
!is_pool_candidate
}
pub fn ai_candidate_extra_data_with_ranking(
extra_data: Option<Value>,
ranking: Option<&SchedulerRankingOutcome>,
) -> Option<Value> {
let Some(ranking) = ranking else {
return extra_data;
};
let mut object = match extra_data {
Some(Value::Object(object)) => object,
Some(value) => {
let mut object = serde_json::Map::new();
object.insert("extra".to_string(), value);
object
}
None => serde_json::Map::new(),
};
crate::append_ai_ranking_metadata_to_object(&mut object, ranking);
Some(Value::Object(object))
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
#[derive(Debug, Clone, PartialEq, Eq)]
struct TestCandidate {
id: &'static str,
attempt_slots: u32,
persist: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct TestAttempt {
id: &'static str,
candidate_index: u32,
retry_index: u32,
candidate_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct TestSkipped {
id: &'static str,
persist: bool,
}
#[derive(Default)]
struct TestPort {
next_id: Mutex<u32>,
calls: Mutex<Vec<String>>,
}
impl TestPort {
fn next_candidate_id(&self) -> String {
let mut next_id = self.next_id.lock().unwrap();
*next_id += 1;
format!("candidate-{next_id}")
}
}
#[async_trait]
impl AiAvailableCandidatePersistencePort for TestPort {
type Candidate = TestCandidate;
type Attempt = TestAttempt;
type ExtraData = String;
type Error = std::convert::Infallible;
fn attempt_slot_count(&self, candidate: &Self::Candidate) -> u32 {
candidate.attempt_slots
}
fn build_extra_data(&self, candidate: &Self::Candidate) -> Option<Self::ExtraData> {
Some(format!("extra:{}", candidate.id))
}
fn generate_candidate_id(&self) -> String {
self.next_candidate_id()
}
fn should_persist_available_candidate(&self, candidate: &Self::Candidate) -> bool {
candidate.persist
}
async fn persist_available_candidate(
&self,
candidate: &Self::Candidate,
candidate_index: u32,
retry_index: u32,
generated_candidate_id: &str,
extra_data: Option<Self::ExtraData>,
) -> Result<String, Self::Error> {
self.calls.lock().unwrap().push(format!(
"available:{}:{candidate_index}:{retry_index}:{generated_candidate_id}:{}",
candidate.id,
extra_data.unwrap_or_default()
));
Ok(format!("stored-{generated_candidate_id}"))
}
fn build_attempt(
&self,
candidate: Self::Candidate,
candidate_index: u32,
retry_index: u32,
candidate_id: String,
) -> Self::Attempt {
TestAttempt {
id: candidate.id,
candidate_index,
retry_index,
candidate_id,
}
}
}
#[async_trait]
impl AiSkippedCandidatePersistencePort for TestPort {
type Skipped = TestSkipped;
type ExtraData = String;
type Error = std::convert::Infallible;
fn should_persist_skipped_candidate(&self, candidate: &Self::Skipped) -> bool {
candidate.persist
}
fn build_extra_data(&self, candidate: &Self::Skipped) -> Option<Self::ExtraData> {
Some(format!("extra:{}", candidate.id))
}
fn generate_candidate_id(&self) -> String {
self.next_candidate_id()
}
async fn persist_skipped_candidate(
&self,
candidate: &Self::Skipped,
candidate_index: u32,
generated_candidate_id: &str,
extra_data: Option<Self::ExtraData>,
) -> Result<(), Self::Error> {
self.calls.lock().unwrap().push(format!(
"skipped:{}:{candidate_index}:{generated_candidate_id}:{}",
candidate.id,
extra_data.unwrap_or_default()
));
Ok(())
}
}
#[tokio::test]
async fn available_persistence_expands_candidates_into_retry_attempts() {
let port = TestPort::default();
let attempts = run_ai_available_candidate_persistence(
&port,
vec![
TestCandidate {
id: "a",
attempt_slots: 2,
persist: true,
},
TestCandidate {
id: "b",
attempt_slots: 1,
persist: false,
},
],
)
.await
.unwrap();
assert_eq!(
attempts,
[
TestAttempt {
id: "a",
candidate_index: 0,
retry_index: 0,
candidate_id: "stored-candidate-1".to_string(),
},
TestAttempt {
id: "a",
candidate_index: 0,
retry_index: 1,
candidate_id: "stored-candidate-2".to_string(),
},
TestAttempt {
id: "b",
candidate_index: 1,
retry_index: 0,
candidate_id: "candidate-3".to_string(),
},
]
);
assert_eq!(
port.calls.lock().unwrap().as_slice(),
[
"available:a:0:0:candidate-1:extra:a",
"available:a:0:1:candidate-2:extra:a",
]
);
}
#[tokio::test]
async fn skipped_persistence_keeps_indices_for_persisted_candidates_only() {
let port = TestPort::default();
run_ai_skipped_candidate_persistence(
&port,
3,
vec![
TestSkipped {
id: "ignored",
persist: false,
},
TestSkipped {
id: "a",
persist: true,
},
TestSkipped {
id: "b",
persist: true,
},
],
)
.await
.unwrap();
assert_eq!(
port.calls.lock().unwrap().as_slice(),
[
"skipped:a:3:candidate-1:extra:a",
"skipped:b:4:candidate-2:extra:b",
]
);
}
#[test]
fn pool_candidate_persistence_policy_skips_pool_keys_until_execution() {
assert!(ai_should_persist_available_candidate_for_pool_key(None));
assert!(!ai_should_persist_available_candidate_for_pool_key(Some(0)));
assert!(!ai_should_persist_available_candidate_for_pool_key(Some(1)));
assert!(ai_should_persist_skipped_candidate_for_pool_membership(
false
));
assert!(!ai_should_persist_skipped_candidate_for_pool_membership(
true
));
}
#[test]
fn candidate_extra_data_with_ranking_preserves_existing_shapes() {
let ranking = SchedulerRankingOutcome {
original_index: 1,
ranking_index: 0,
priority_mode: aether_scheduler_core::SchedulerPriorityMode::Provider,
ranking_mode: aether_scheduler_core::SchedulerRankingMode::CacheAffinity,
priority_slot: 3,
promoted_by: Some("cached_affinity"),
demoted_by: None,
};
let object = ai_candidate_extra_data_with_ranking(
Some(serde_json::json!({"source": "test"})),
Some(&ranking),
)
.expect("extra data should exist");
assert_eq!(object.get("source"), Some(&serde_json::json!("test")));
assert_eq!(object.get("ranking_index"), Some(&serde_json::json!(0)));
assert_eq!(
object.get("promoted_by"),
Some(&serde_json::json!("cached_affinity"))
);
let scalar =
ai_candidate_extra_data_with_ranking(Some(serde_json::json!("raw")), Some(&ranking))
.expect("scalar extra data should be wrapped");
assert_eq!(scalar.get("extra"), Some(&serde_json::json!("raw")));
assert_eq!(scalar.get("priority_slot"), Some(&serde_json::json!(3)));
}
}
@@ -0,0 +1,117 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(clippy::enum_variant_names)]
pub enum AiCandidatePersistencePolicyKind {
StandardDecision,
SameFormatProviderDecision,
OpenAiChatDecision,
OpenAiResponsesDecision,
ImageDecision,
GeminiFilesDecision,
VideoDecision,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AiCandidatePersistencePolicySpec {
pub available_error_context: &'static str,
pub skipped_error_context: &'static str,
pub record_runtime_miss_diagnostic: bool,
}
pub fn ai_candidate_persistence_policy_spec(
kind: AiCandidatePersistencePolicyKind,
) -> AiCandidatePersistencePolicySpec {
match kind {
AiCandidatePersistencePolicyKind::StandardDecision => AiCandidatePersistencePolicySpec {
available_error_context:
"gateway local standard decision request candidate upsert failed",
skipped_error_context:
"gateway local standard decision failed to persist skipped candidate",
record_runtime_miss_diagnostic: true,
},
AiCandidatePersistencePolicyKind::SameFormatProviderDecision => {
AiCandidatePersistencePolicySpec {
available_error_context:
"gateway local same-format decision request candidate upsert failed",
skipped_error_context:
"gateway local same-format decision failed to persist skipped candidate",
record_runtime_miss_diagnostic: true,
}
}
AiCandidatePersistencePolicyKind::OpenAiChatDecision => AiCandidatePersistencePolicySpec {
available_error_context:
"gateway local openai chat decision request candidate upsert failed",
skipped_error_context:
"gateway local openai chat decision failed to persist skipped candidate",
record_runtime_miss_diagnostic: true,
},
AiCandidatePersistencePolicyKind::OpenAiResponsesDecision => {
AiCandidatePersistencePolicySpec {
available_error_context:
"gateway local openai responses decision request candidate upsert failed",
skipped_error_context:
"gateway local openai responses decision failed to persist skipped candidate",
record_runtime_miss_diagnostic: true,
}
}
AiCandidatePersistencePolicyKind::ImageDecision => AiCandidatePersistencePolicySpec {
available_error_context:
"gateway local openai image decision request candidate upsert failed",
skipped_error_context:
"gateway local openai image decision failed to persist skipped candidate",
record_runtime_miss_diagnostic: false,
},
AiCandidatePersistencePolicyKind::GeminiFilesDecision => AiCandidatePersistencePolicySpec {
available_error_context: "gateway local gemini files request candidate upsert failed",
skipped_error_context: "gateway local gemini files failed to persist skipped candidate",
record_runtime_miss_diagnostic: false,
},
AiCandidatePersistencePolicyKind::VideoDecision => AiCandidatePersistencePolicySpec {
available_error_context: "gateway local video decision request candidate upsert failed",
skipped_error_context:
"gateway local video decision failed to persist skipped candidate",
record_runtime_miss_diagnostic: false,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decision_policies_define_candidate_persistence_side_effects() {
let standard = ai_candidate_persistence_policy_spec(
AiCandidatePersistencePolicyKind::StandardDecision,
);
assert_eq!(
standard.available_error_context,
"gateway local standard decision request candidate upsert failed"
);
assert_eq!(
standard.skipped_error_context,
"gateway local standard decision failed to persist skipped candidate"
);
assert!(standard.record_runtime_miss_diagnostic);
let same_format = ai_candidate_persistence_policy_spec(
AiCandidatePersistencePolicyKind::SameFormatProviderDecision,
);
assert_eq!(
same_format.available_error_context,
"gateway local same-format decision request candidate upsert failed"
);
assert_eq!(
same_format.skipped_error_context,
"gateway local same-format decision failed to persist skipped candidate"
);
assert!(same_format.record_runtime_miss_diagnostic);
let image =
ai_candidate_persistence_policy_spec(AiCandidatePersistencePolicyKind::ImageDecision);
assert_eq!(
image.available_error_context,
"gateway local openai image decision request candidate upsert failed"
);
assert!(!image.record_runtime_miss_diagnostic);
}
}
@@ -0,0 +1,91 @@
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AiPreparedHeaderAuthenticatedCandidate {
pub auth_header: String,
pub auth_value: String,
pub mapped_model: String,
}
pub fn prepare_ai_header_authenticated_candidate(
direct_auth: Option<(String, String)>,
oauth_header_auth: Option<(String, String)>,
selected_provider_model_name: &str,
) -> Result<AiPreparedHeaderAuthenticatedCandidate, &'static str> {
let Some((auth_header, auth_value)) = direct_auth.or(oauth_header_auth) else {
return Err("transport_auth_unavailable");
};
let mapped_model = resolve_ai_candidate_mapped_model(selected_provider_model_name)?;
Ok(AiPreparedHeaderAuthenticatedCandidate {
auth_header,
auth_value,
mapped_model,
})
}
pub fn resolve_ai_candidate_mapped_model(
selected_provider_model_name: &str,
) -> Result<String, &'static str> {
let mapped_model = selected_provider_model_name.trim().to_string();
if mapped_model.is_empty() {
return Err("mapped_model_missing");
}
Ok(mapped_model)
}
#[cfg(test)]
mod tests {
use super::{prepare_ai_header_authenticated_candidate, resolve_ai_candidate_mapped_model};
#[test]
fn mapped_model_trims_selected_provider_model_name() {
assert_eq!(
resolve_ai_candidate_mapped_model(" gpt-test-upstream "),
Ok("gpt-test-upstream".to_string())
);
}
#[test]
fn mapped_model_rejects_empty_selected_provider_model_name() {
assert_eq!(
resolve_ai_candidate_mapped_model(" "),
Err("mapped_model_missing")
);
}
#[test]
fn header_auth_preparation_prefers_direct_auth_and_allows_empty_value() {
let prepared = prepare_ai_header_authenticated_candidate(
Some(("authorization".to_string(), String::new())),
Some(("x-oauth".to_string(), "oauth".to_string())),
"gpt-test-upstream",
)
.expect("direct auth should prepare candidate");
assert_eq!(prepared.auth_header, "authorization");
assert_eq!(prepared.auth_value, "");
assert_eq!(prepared.mapped_model, "gpt-test-upstream");
}
#[test]
fn header_auth_preparation_falls_back_to_oauth_header_auth() {
let prepared = prepare_ai_header_authenticated_candidate(
None,
Some(("authorization".to_string(), "Bearer token".to_string())),
" gpt-test-upstream ",
)
.expect("oauth header auth should prepare candidate");
assert_eq!(prepared.auth_header, "authorization");
assert_eq!(prepared.auth_value, "Bearer token");
assert_eq!(prepared.mapped_model, "gpt-test-upstream");
}
#[test]
fn header_auth_preparation_requires_some_auth() {
assert_eq!(
prepare_ai_header_authenticated_candidate(None, None, "gpt-test-upstream"),
Err("transport_auth_unavailable")
);
}
}
@@ -0,0 +1,185 @@
use std::collections::BTreeSet;
use async_trait::async_trait;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AiCandidatePreselectionOutcome<Candidate, Skipped> {
pub candidates: Vec<Candidate>,
pub skipped_candidates: Vec<Skipped>,
}
#[async_trait]
pub trait AiCandidatePreselectionPort: Send + Sync {
type Candidate: Send;
type Skipped: Send;
type Error: Send;
fn candidate_api_formats(&self) -> Vec<String>;
fn candidate_api_format_matches_client(&self, candidate_api_format: &str) -> bool;
async fn list_candidates_for_api_format(
&self,
candidate_api_format: &str,
matches_client_format: bool,
) -> Result<(Vec<Self::Candidate>, Vec<Self::Skipped>), Self::Error>;
fn candidate_allowed(
&self,
_candidate: &Self::Candidate,
_candidate_api_format: &str,
_matches_client_format: bool,
) -> bool {
true
}
fn skipped_candidate_allowed(
&self,
_skipped_candidate: &Self::Skipped,
_candidate_api_format: &str,
_matches_client_format: bool,
) -> bool {
true
}
fn candidate_key(&self, candidate: &Self::Candidate) -> String;
fn skipped_candidate_key(&self, skipped_candidate: &Self::Skipped) -> String;
}
pub async fn run_ai_candidate_preselection<Port>(
port: &Port,
) -> Result<AiCandidatePreselectionOutcome<Port::Candidate, Port::Skipped>, Port::Error>
where
Port: AiCandidatePreselectionPort,
{
let mut candidates = Vec::new();
let mut skipped_candidates = Vec::new();
let mut seen_candidates = BTreeSet::new();
let mut seen_skipped_candidates = BTreeSet::new();
for candidate_api_format in port.candidate_api_formats() {
let matches_client_format =
port.candidate_api_format_matches_client(candidate_api_format.as_str());
let (selected, skipped) = port
.list_candidates_for_api_format(candidate_api_format.as_str(), matches_client_format)
.await?;
for skipped_candidate in skipped {
if !port.skipped_candidate_allowed(
&skipped_candidate,
candidate_api_format.as_str(),
matches_client_format,
) {
continue;
}
let candidate_key = port.skipped_candidate_key(&skipped_candidate);
if seen_skipped_candidates.insert(candidate_key) {
skipped_candidates.push(skipped_candidate);
}
}
for candidate in selected {
if !port.candidate_allowed(
&candidate,
candidate_api_format.as_str(),
matches_client_format,
) {
continue;
}
let candidate_key = port.candidate_key(&candidate);
if seen_candidates.insert(candidate_key) {
candidates.push(candidate);
}
}
}
Ok(AiCandidatePreselectionOutcome {
candidates,
skipped_candidates,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
#[derive(Default)]
struct TestPort {
calls: Mutex<Vec<String>>,
}
#[async_trait]
impl AiCandidatePreselectionPort for TestPort {
type Candidate = &'static str;
type Skipped = &'static str;
type Error = std::convert::Infallible;
fn candidate_api_formats(&self) -> Vec<String> {
vec!["same".to_string(), "cross".to_string()]
}
fn candidate_api_format_matches_client(&self, candidate_api_format: &str) -> bool {
candidate_api_format == "same"
}
async fn list_candidates_for_api_format(
&self,
candidate_api_format: &str,
matches_client_format: bool,
) -> Result<(Vec<Self::Candidate>, Vec<Self::Skipped>), Self::Error> {
self.calls.lock().unwrap().push(format!(
"list:{candidate_api_format}:{matches_client_format}"
));
Ok(match candidate_api_format {
"same" => (vec!["candidate-a"], vec!["skip-a"]),
"cross" => (
vec!["candidate-a", "candidate-b", "blocked-candidate"],
vec!["skip-a", "skip-b", "blocked-skip"],
),
_ => (Vec::new(), Vec::new()),
})
}
fn candidate_allowed(
&self,
candidate: &Self::Candidate,
_candidate_api_format: &str,
matches_client_format: bool,
) -> bool {
matches_client_format || *candidate != "blocked-candidate"
}
fn skipped_candidate_allowed(
&self,
skipped_candidate: &Self::Skipped,
_candidate_api_format: &str,
matches_client_format: bool,
) -> bool {
matches_client_format || *skipped_candidate != "blocked-skip"
}
fn candidate_key(&self, candidate: &Self::Candidate) -> String {
(*candidate).to_string()
}
fn skipped_candidate_key(&self, skipped_candidate: &Self::Skipped) -> String {
(*skipped_candidate).to_string()
}
}
#[tokio::test]
async fn preselection_runs_formats_in_order_filters_cross_format_and_dedupes() {
let port = TestPort::default();
let outcome = run_ai_candidate_preselection(&port).await.unwrap();
assert_eq!(outcome.candidates, ["candidate-a", "candidate-b"]);
assert_eq!(outcome.skipped_candidates, ["skip-a", "skip-b"]);
assert_eq!(
port.calls.lock().unwrap().as_slice(),
["list:same:true", "list:cross:false"]
);
}
}
@@ -0,0 +1,324 @@
use aether_scheduler_core::{
apply_scheduler_candidate_ranking, requested_capability_priority_for_candidate,
SchedulerMinimalCandidateSelectionCandidate, SchedulerPriorityMode, SchedulerRankableCandidate,
SchedulerRankingContext, SchedulerRankingMode, SchedulerRankingOutcome,
SchedulerTunnelAffinityBucket,
};
use async_trait::async_trait;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AiRankingSchedulingMode {
FixedOrder,
CacheAffinity,
LoadBalance,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AiRankingContextConfig {
pub priority_mode: SchedulerPriorityMode,
pub scheduling_mode: AiRankingSchedulingMode,
pub load_balance_seed: u64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AiRankableCandidateParts<'a> {
pub candidate: &'a SchedulerMinimalCandidateSelectionCandidate,
pub original_index: usize,
pub normalized_client_api_format: &'a str,
pub provider_api_format: &'a str,
pub required_capabilities: Option<&'a serde_json::Value>,
pub cached_affinity_match: bool,
pub tunnel_bucket: SchedulerTunnelAffinityBucket,
pub keep_priority_on_conversion: bool,
}
#[async_trait]
pub trait AiCandidateRankingPort: Send + Sync {
type Candidate: Send + Sync;
type AffinityTarget: Send + Sync;
type Error: Send;
fn affinity_requested_model(&self, candidates: &[Self::Candidate]) -> Option<String>;
async fn read_cached_affinity_target(
&self,
normalized_client_api_format: &str,
affinity_requested_model: Option<&str>,
) -> Result<Option<Self::AffinityTarget>, Self::Error>;
fn cached_affinity_matches(
&self,
candidate: &Self::Candidate,
target: &Self::AffinityTarget,
) -> bool;
async fn build_rankable_candidate(
&self,
candidate: &Self::Candidate,
original_index: usize,
normalized_client_api_format: &str,
cached_affinity_match: bool,
) -> Result<SchedulerRankableCandidate, Self::Error>;
fn ranking_context(&self) -> SchedulerRankingContext;
fn apply_ranking_outcome(
&self,
candidate: &mut Self::Candidate,
outcome: SchedulerRankingOutcome,
);
}
pub fn build_ai_rankable_candidate(
parts: AiRankableCandidateParts<'_>,
) -> SchedulerRankableCandidate {
let is_same_format = aether_ai_formats::api_format_alias_matches(
parts.provider_api_format,
parts.normalized_client_api_format,
);
let format_preference = aether_ai_formats::request_candidate_api_format_preference(
parts.normalized_client_api_format,
parts.provider_api_format,
)
.unwrap_or((u8::MAX, u8::MAX));
SchedulerRankableCandidate::from_candidate(parts.candidate, parts.original_index)
.with_capability_priority(requested_capability_priority_for_candidate(
parts.required_capabilities,
parts.candidate,
))
.with_cached_affinity_match(parts.cached_affinity_match)
.with_tunnel_bucket(parts.tunnel_bucket)
.with_format_state(
!is_same_format && !parts.keep_priority_on_conversion,
format_preference,
)
}
pub fn ai_ranking_context(config: AiRankingContextConfig) -> SchedulerRankingContext {
SchedulerRankingContext {
priority_mode: config.priority_mode,
ranking_mode: ai_ranking_mode(config.scheduling_mode),
include_health: false,
load_balance_seed: config.load_balance_seed,
}
}
fn ai_ranking_mode(mode: AiRankingSchedulingMode) -> SchedulerRankingMode {
match mode {
AiRankingSchedulingMode::FixedOrder => SchedulerRankingMode::FixedOrder,
AiRankingSchedulingMode::CacheAffinity => SchedulerRankingMode::CacheAffinity,
AiRankingSchedulingMode::LoadBalance => SchedulerRankingMode::LoadBalance,
}
}
pub async fn run_ai_candidate_ranking<Port>(
port: &Port,
mut candidates: Vec<Port::Candidate>,
normalized_client_api_format: &str,
) -> Result<Vec<Port::Candidate>, Port::Error>
where
Port: AiCandidateRankingPort,
{
let ranking_context = port.ranking_context();
let cached_affinity_target =
if ranking_context.ranking_mode == SchedulerRankingMode::CacheAffinity {
let affinity_requested_model = port.affinity_requested_model(&candidates);
port.read_cached_affinity_target(
normalized_client_api_format,
affinity_requested_model.as_deref(),
)
.await?
} else {
None
};
let mut rankables = Vec::with_capacity(candidates.len());
for (original_index, candidate) in candidates.iter().enumerate() {
let cached_affinity_match = cached_affinity_target
.as_ref()
.is_some_and(|target| port.cached_affinity_matches(candidate, target));
rankables.push(
port.build_rankable_candidate(
candidate,
original_index,
normalized_client_api_format,
cached_affinity_match,
)
.await?,
);
}
let outcomes = apply_scheduler_candidate_ranking(&mut candidates, &rankables, ranking_context);
for outcome in outcomes {
let ranking_index = outcome.ranking_index;
if let Some(candidate) = candidates.get_mut(ranking_index) {
port.apply_ranking_outcome(candidate, outcome);
}
}
Ok(candidates)
}
#[cfg(test)]
mod tests {
use super::*;
use aether_scheduler_core::{SchedulerPriorityMode, SchedulerRankingMode};
use std::sync::Mutex;
#[derive(Debug, Clone, PartialEq, Eq)]
struct TestCandidate {
id: &'static str,
priority: i32,
ranking_index: Option<usize>,
cached_affinity: bool,
}
#[derive(Default)]
struct TestPort {
ranking_mode: SchedulerRankingMode,
calls: Mutex<Vec<String>>,
}
#[async_trait]
impl AiCandidateRankingPort for TestPort {
type Candidate = TestCandidate;
type AffinityTarget = &'static str;
type Error = std::convert::Infallible;
fn affinity_requested_model(&self, candidates: &[Self::Candidate]) -> Option<String> {
candidates.first().map(|_| "model-a".to_string())
}
async fn read_cached_affinity_target(
&self,
normalized_client_api_format: &str,
affinity_requested_model: Option<&str>,
) -> Result<Option<Self::AffinityTarget>, Self::Error> {
self.calls.lock().unwrap().push(format!(
"affinity:{normalized_client_api_format}:{}",
affinity_requested_model.unwrap_or_default()
));
Ok(Some("candidate-b"))
}
fn cached_affinity_matches(
&self,
candidate: &Self::Candidate,
target: &Self::AffinityTarget,
) -> bool {
candidate.id == *target
}
async fn build_rankable_candidate(
&self,
candidate: &Self::Candidate,
original_index: usize,
_normalized_client_api_format: &str,
cached_affinity_match: bool,
) -> Result<SchedulerRankableCandidate, Self::Error> {
self.calls
.lock()
.unwrap()
.push(format!("rankable:{}:{cached_affinity_match}", candidate.id));
Ok(SchedulerRankableCandidate {
provider_id: candidate.id.to_string(),
endpoint_id: String::new(),
key_id: String::new(),
selected_provider_model_name: String::new(),
provider_priority: candidate.priority,
key_internal_priority: 0,
key_global_priority_for_format: None,
capability_priority: (0, 0),
cached_affinity_match,
affinity_hash: None,
tunnel_bucket: Default::default(),
demote_cross_format: false,
format_preference: (0, 0),
health_bucket: None,
health_score: 1.0,
original_index,
})
}
fn ranking_context(&self) -> SchedulerRankingContext {
SchedulerRankingContext {
priority_mode: SchedulerPriorityMode::Provider,
ranking_mode: self.ranking_mode,
include_health: false,
load_balance_seed: 0,
}
}
fn apply_ranking_outcome(
&self,
candidate: &mut Self::Candidate,
outcome: SchedulerRankingOutcome,
) {
candidate.ranking_index = Some(outcome.ranking_index);
candidate.cached_affinity = outcome.promoted_by.is_some();
}
}
#[tokio::test]
async fn ranking_builds_rankables_applies_scheduler_order_and_writes_outcomes() {
let port = TestPort::default();
let candidates = vec![
TestCandidate {
id: "candidate-a",
priority: 10,
ranking_index: None,
cached_affinity: false,
},
TestCandidate {
id: "candidate-b",
priority: 20,
ranking_index: None,
cached_affinity: false,
},
];
let ranked = run_ai_candidate_ranking(&port, candidates, "openai:chat")
.await
.unwrap();
assert_eq!(ranked[0].id, "candidate-b");
assert_eq!(ranked[0].ranking_index, Some(0));
assert!(ranked[0].cached_affinity);
assert_eq!(ranked[1].id, "candidate-a");
assert_eq!(ranked[1].ranking_index, Some(1));
assert_eq!(
port.calls.lock().unwrap().as_slice(),
[
"affinity:openai:chat:model-a",
"rankable:candidate-a:false",
"rankable:candidate-b:true",
]
);
}
#[tokio::test]
async fn non_cache_affinity_ranking_does_not_read_affinity_target() {
let port = TestPort {
ranking_mode: SchedulerRankingMode::LoadBalance,
calls: Mutex::new(Vec::new()),
};
let candidates = vec![TestCandidate {
id: "candidate-a",
priority: 10,
ranking_index: None,
cached_affinity: false,
}];
let ranked = run_ai_candidate_ranking(&port, candidates, "openai:chat")
.await
.unwrap();
assert_eq!(ranked[0].id, "candidate-a");
assert!(!ranked[0].cached_affinity);
assert_eq!(
port.calls.lock().unwrap().as_slice(),
["rankable:candidate-a:false"]
);
}
}
@@ -0,0 +1,457 @@
use async_trait::async_trait;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AiCandidateResolutionMode {
Standard,
WithoutTransportPairGate,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AiCandidateResolutionRequest<'a> {
pub client_api_format: &'a str,
pub requested_model: Option<&'a str>,
pub mode: AiCandidateResolutionMode,
pub expand_pool_groups: bool,
}
impl<'a> AiCandidateResolutionRequest<'a> {
pub fn standard(client_api_format: &'a str, requested_model: Option<&'a str>) -> Self {
Self {
client_api_format,
requested_model,
mode: AiCandidateResolutionMode::Standard,
expand_pool_groups: false,
}
}
pub fn without_transport_pair_gate(
client_api_format: &'a str,
requested_model: Option<&'a str>,
) -> Self {
Self {
client_api_format,
requested_model,
mode: AiCandidateResolutionMode::WithoutTransportPairGate,
expand_pool_groups: false,
}
}
pub fn logical_pool_groups(mut self) -> Self {
self.expand_pool_groups = false;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AiCandidateResolutionOutcome<Eligible, Skipped> {
pub eligible_candidates: Vec<Eligible>,
pub skipped_candidates: Vec<Skipped>,
}
#[async_trait]
pub trait AiCandidateResolutionPort: Send + Sync {
type Candidate: Send;
type Transport: Send + Sync;
type Eligible: Send + Sync;
type Skipped: Send;
type Error: Send;
async fn read_candidate_transport(
&self,
candidate: &Self::Candidate,
) -> Result<Option<Self::Transport>, Self::Error>;
fn build_missing_transport_skipped_candidate(
&self,
candidate: Self::Candidate,
) -> Self::Skipped;
fn candidate_common_skip_reason(
&self,
candidate: &Self::Candidate,
transport: &Self::Transport,
requested_model: Option<&str>,
) -> Option<&'static str>;
fn candidate_transport_pair_skip_reason(
&self,
candidate: &Self::Candidate,
transport: &Self::Transport,
normalized_client_api_format: &str,
requested_model: &str,
) -> Option<&'static str>;
fn build_skipped_candidate(
&self,
candidate: Self::Candidate,
transport: Self::Transport,
skip_reason: &'static str,
) -> Self::Skipped;
fn build_eligible_candidate(
&self,
candidate: Self::Candidate,
transport: Self::Transport,
) -> Self::Eligible;
async fn rank_eligible_candidates(
&self,
candidates: Vec<Self::Eligible>,
normalized_client_api_format: &str,
) -> Result<Vec<Self::Eligible>, Self::Error>;
async fn apply_pool_scheduler(
&self,
candidates: Vec<Self::Eligible>,
) -> Result<(Vec<Self::Eligible>, Vec<Self::Skipped>), Self::Error>;
}
pub async fn run_ai_candidate_resolution<Port>(
port: &Port,
candidates: Vec<Port::Candidate>,
request: AiCandidateResolutionRequest<'_>,
) -> Result<AiCandidateResolutionOutcome<Port::Eligible, Port::Skipped>, Port::Error>
where
Port: AiCandidateResolutionPort,
{
let normalized_client_api_format = request.client_api_format.trim().to_ascii_lowercase();
let requested_model = request
.requested_model
.map(str::trim)
.filter(|value| !value.is_empty());
let mut eligible = Vec::with_capacity(candidates.len());
let mut skipped = Vec::with_capacity(candidates.len());
for candidate in candidates {
let Some(transport) = port.read_candidate_transport(&candidate).await? else {
skipped.push(port.build_missing_transport_skipped_candidate(candidate));
continue;
};
match candidate_skip_reason_for_mode(
port,
request.mode,
&candidate,
&transport,
normalized_client_api_format.as_str(),
requested_model,
) {
Some(skip_reason) => {
skipped.push(port.build_skipped_candidate(candidate, transport, skip_reason));
}
None => {
eligible.push(port.build_eligible_candidate(candidate, transport));
}
}
}
let ranked = port
.rank_eligible_candidates(eligible, normalized_client_api_format.as_str())
.await?;
let ranked = if request.expand_pool_groups {
let (ranked, pool_skipped) = port.apply_pool_scheduler(ranked).await?;
skipped.extend(pool_skipped);
ranked
} else {
ranked
};
Ok(AiCandidateResolutionOutcome {
eligible_candidates: ranked,
skipped_candidates: skipped,
})
}
fn candidate_skip_reason_for_mode<Port>(
port: &Port,
mode: AiCandidateResolutionMode,
candidate: &Port::Candidate,
transport: &Port::Transport,
normalized_client_api_format: &str,
requested_model: Option<&str>,
) -> Option<&'static str>
where
Port: AiCandidateResolutionPort,
{
port.candidate_common_skip_reason(candidate, transport, requested_model)
.or_else(|| match mode {
AiCandidateResolutionMode::Standard => port.candidate_transport_pair_skip_reason(
candidate,
transport,
normalized_client_api_format,
requested_model.unwrap_or_default(),
),
AiCandidateResolutionMode::WithoutTransportPairGate => None,
})
}
pub fn extract_ai_pool_sticky_session_token(body_json: &serde_json::Value) -> Option<String> {
fn non_empty_str(value: Option<&serde_json::Value>) -> Option<&str> {
value
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
}
let object = body_json.as_object()?;
non_empty_str(object.get("prompt_cache_key"))
.or_else(|| non_empty_str(object.get("conversation_id")))
.or_else(|| non_empty_str(object.get("conversationId")))
.or_else(|| non_empty_str(object.get("session_id")))
.or_else(|| non_empty_str(object.get("sessionId")))
.or_else(|| {
object
.get("metadata")
.and_then(serde_json::Value::as_object)
.and_then(|metadata| {
non_empty_str(metadata.get("session_id"))
.or_else(|| non_empty_str(metadata.get("conversation_id")))
})
})
.or_else(|| {
object
.get("conversationState")
.and_then(serde_json::Value::as_object)
.and_then(|state| {
non_empty_str(state.get("conversationId"))
.or_else(|| non_empty_str(state.get("sessionId")))
})
})
.map(ToOwned::to_owned)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::sync::Mutex;
#[derive(Default)]
struct TestPort {
calls: Mutex<Vec<String>>,
}
#[async_trait]
impl AiCandidateResolutionPort for TestPort {
type Candidate = &'static str;
type Transport = &'static str;
type Eligible = String;
type Skipped = String;
type Error = std::convert::Infallible;
async fn read_candidate_transport(
&self,
candidate: &Self::Candidate,
) -> Result<Option<Self::Transport>, Self::Error> {
self.calls
.lock()
.unwrap()
.push(format!("transport:{candidate}"));
Ok(match *candidate {
"missing" => None,
"inactive" => Some("inactive-transport"),
_ => Some("active-transport"),
})
}
fn build_missing_transport_skipped_candidate(
&self,
candidate: Self::Candidate,
) -> Self::Skipped {
format!("{candidate}:transport_snapshot_missing")
}
fn candidate_common_skip_reason(
&self,
candidate: &Self::Candidate,
_transport: &Self::Transport,
requested_model: Option<&str>,
) -> Option<&'static str> {
self.calls.lock().unwrap().push(format!(
"common:{candidate}:{}",
requested_model.unwrap_or_default()
));
(*candidate == "inactive").then_some("provider_inactive")
}
fn candidate_transport_pair_skip_reason(
&self,
candidate: &Self::Candidate,
_transport: &Self::Transport,
normalized_client_api_format: &str,
requested_model: &str,
) -> Option<&'static str> {
self.calls.lock().unwrap().push(format!(
"pair:{candidate}:{normalized_client_api_format}:{requested_model}"
));
(*candidate == "unsupported").then_some("transport_unsupported")
}
fn build_skipped_candidate(
&self,
candidate: Self::Candidate,
_transport: Self::Transport,
skip_reason: &'static str,
) -> Self::Skipped {
format!("{candidate}:{skip_reason}")
}
fn build_eligible_candidate(
&self,
candidate: Self::Candidate,
_transport: Self::Transport,
) -> Self::Eligible {
format!("eligible:{candidate}")
}
async fn rank_eligible_candidates(
&self,
mut candidates: Vec<Self::Eligible>,
normalized_client_api_format: &str,
) -> Result<Vec<Self::Eligible>, Self::Error> {
self.calls
.lock()
.unwrap()
.push(format!("rank:{normalized_client_api_format}"));
candidates.reverse();
Ok(candidates)
}
async fn apply_pool_scheduler(
&self,
candidates: Vec<Self::Eligible>,
) -> Result<(Vec<Self::Eligible>, Vec<Self::Skipped>), Self::Error> {
self.calls.lock().unwrap().push("pool".to_string());
Ok((candidates, vec!["pool:cooldown".to_string()]))
}
}
#[tokio::test]
async fn resolution_reads_transport_gates_candidates_then_ranks_without_expanding_pools() {
let port = TestPort::default();
let outcome = run_ai_candidate_resolution(
&port,
vec!["first", "missing", "inactive", "unsupported", "second"],
AiCandidateResolutionRequest::standard(" OpenAI:Chat ", Some(" gpt-4.1 ")),
)
.await
.unwrap();
assert_eq!(
outcome.eligible_candidates,
["eligible:second", "eligible:first"]
);
assert_eq!(
outcome.skipped_candidates,
[
"missing:transport_snapshot_missing",
"inactive:provider_inactive",
"unsupported:transport_unsupported",
]
);
assert_eq!(
port.calls.lock().unwrap().as_slice(),
[
"transport:first",
"common:first:gpt-4.1",
"pair:first:openai:chat:gpt-4.1",
"transport:missing",
"transport:inactive",
"common:inactive:gpt-4.1",
"transport:unsupported",
"common:unsupported:gpt-4.1",
"pair:unsupported:openai:chat:gpt-4.1",
"transport:second",
"common:second:gpt-4.1",
"pair:second:openai:chat:gpt-4.1",
"rank:openai:chat",
]
);
}
#[tokio::test]
async fn resolution_mode_can_skip_transport_pair_gate() {
let port = TestPort::default();
let outcome = run_ai_candidate_resolution(
&port,
vec!["unsupported"],
AiCandidateResolutionRequest::without_transport_pair_gate("openai:chat", None),
)
.await
.unwrap();
assert_eq!(outcome.eligible_candidates, ["eligible:unsupported"]);
assert!(outcome.skipped_candidates.is_empty());
assert_eq!(
port.calls.lock().unwrap().as_slice(),
[
"transport:unsupported",
"common:unsupported:",
"rank:openai:chat",
]
);
}
#[tokio::test]
async fn resolution_can_keep_pool_groups_logical() {
let port = TestPort::default();
let outcome = run_ai_candidate_resolution(
&port,
vec!["first", "second"],
AiCandidateResolutionRequest::standard("openai:chat", Some("gpt-4.1"))
.logical_pool_groups(),
)
.await
.unwrap();
assert_eq!(
outcome.eligible_candidates,
["eligible:second", "eligible:first"]
);
assert!(outcome.skipped_candidates.is_empty());
assert_eq!(
port.calls.lock().unwrap().as_slice(),
[
"transport:first",
"common:first:gpt-4.1",
"pair:first:openai:chat:gpt-4.1",
"transport:second",
"common:second:gpt-4.1",
"pair:second:openai:chat:gpt-4.1",
"rank:openai:chat",
]
);
}
#[test]
fn sticky_session_token_is_extracted_from_known_request_fields() {
assert_eq!(
extract_ai_pool_sticky_session_token(&json!({
"prompt_cache_key": " cache-a ",
"conversation_id": "conversation-b"
}))
.as_deref(),
Some("cache-a")
);
assert_eq!(
extract_ai_pool_sticky_session_token(&json!({
"metadata": {"conversation_id": " conversation-c "}
}))
.as_deref(),
Some("conversation-c")
);
assert_eq!(
extract_ai_pool_sticky_session_token(&json!({
"conversationState": {"sessionId": " session-d "}
}))
.as_deref(),
Some("session-d")
);
}
}
@@ -0,0 +1,196 @@
use async_trait::async_trait;
#[async_trait]
pub trait AiAuthenticatedDecisionInputPort: Send + Sync {
type AuthContext: Send + Sync;
type AuthSnapshot: Send;
type RequiredCapabilities: Send + Sync;
type ResolvedInput: Send;
type Error: Send;
async fn read_auth_snapshot(
&self,
auth_context: &Self::AuthContext,
) -> Result<Option<Self::AuthSnapshot>, Self::Error>;
async fn resolve_required_capabilities(
&self,
auth_context: &Self::AuthContext,
requested_model: Option<&str>,
explicit_required_capabilities: Option<&Self::RequiredCapabilities>,
) -> Result<Option<Self::RequiredCapabilities>, Self::Error>;
fn build_resolved_input(
&self,
auth_context: Self::AuthContext,
auth_snapshot: Self::AuthSnapshot,
required_capabilities: Option<Self::RequiredCapabilities>,
) -> Self::ResolvedInput;
}
pub async fn run_ai_authenticated_decision_input<Port>(
port: &Port,
auth_context: Port::AuthContext,
requested_model: Option<&str>,
explicit_required_capabilities: Option<&Port::RequiredCapabilities>,
) -> Result<Option<Port::ResolvedInput>, Port::Error>
where
Port: AiAuthenticatedDecisionInputPort,
{
let auth_snapshot = match port.read_auth_snapshot(&auth_context).await? {
Some(snapshot) => snapshot,
None => return Ok(None),
};
let required_capabilities = port
.resolve_required_capabilities(
&auth_context,
requested_model,
explicit_required_capabilities,
)
.await?;
Ok(Some(port.build_resolved_input(
auth_context,
auth_snapshot,
required_capabilities,
)))
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
#[derive(Debug, Clone, PartialEq, Eq)]
struct TestAuthContext {
user_id: &'static str,
api_key_id: &'static str,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct TestResolvedInput {
auth_context: TestAuthContext,
auth_snapshot: &'static str,
required_capabilities: Option<String>,
}
struct TestPort {
auth_snapshot: Option<&'static str>,
calls: Mutex<Vec<String>>,
}
#[async_trait]
impl AiAuthenticatedDecisionInputPort for TestPort {
type AuthContext = TestAuthContext;
type AuthSnapshot = &'static str;
type RequiredCapabilities = String;
type ResolvedInput = TestResolvedInput;
type Error = std::convert::Infallible;
async fn read_auth_snapshot(
&self,
auth_context: &Self::AuthContext,
) -> Result<Option<Self::AuthSnapshot>, Self::Error> {
self.calls.lock().unwrap().push(format!(
"snapshot:{}:{}",
auth_context.user_id, auth_context.api_key_id
));
Ok(self.auth_snapshot)
}
async fn resolve_required_capabilities(
&self,
auth_context: &Self::AuthContext,
requested_model: Option<&str>,
explicit_required_capabilities: Option<&Self::RequiredCapabilities>,
) -> Result<Option<Self::RequiredCapabilities>, Self::Error> {
self.calls.lock().unwrap().push(format!(
"capabilities:{}:{}:{}",
auth_context.user_id,
requested_model.unwrap_or_default(),
explicit_required_capabilities
.map(String::as_str)
.unwrap_or_default()
));
Ok(Some("merged-capabilities".to_string()))
}
fn build_resolved_input(
&self,
auth_context: Self::AuthContext,
auth_snapshot: Self::AuthSnapshot,
required_capabilities: Option<Self::RequiredCapabilities>,
) -> Self::ResolvedInput {
TestResolvedInput {
auth_context,
auth_snapshot,
required_capabilities,
}
}
}
#[tokio::test]
async fn authenticated_decision_input_resolves_snapshot_and_capabilities() {
let port = TestPort {
auth_snapshot: Some("snapshot-a"),
calls: Mutex::new(Vec::new()),
};
let auth_context = TestAuthContext {
user_id: "user-a",
api_key_id: "key-a",
};
let explicit = "explicit-capability".to_string();
let resolved = run_ai_authenticated_decision_input(
&port,
auth_context.clone(),
Some("model-a"),
Some(&explicit),
)
.await
.unwrap();
assert_eq!(
resolved,
Some(TestResolvedInput {
auth_context,
auth_snapshot: "snapshot-a",
required_capabilities: Some("merged-capabilities".to_string()),
})
);
assert_eq!(
port.calls.lock().unwrap().as_slice(),
[
"snapshot:user-a:key-a",
"capabilities:user-a:model-a:explicit-capability",
]
);
}
#[tokio::test]
async fn authenticated_decision_input_stops_when_snapshot_is_missing() {
let port = TestPort {
auth_snapshot: None,
calls: Mutex::new(Vec::new()),
};
let resolved = run_ai_authenticated_decision_input(
&port,
TestAuthContext {
user_id: "user-a",
api_key_id: "key-a",
},
Some("model-a"),
None,
)
.await
.unwrap();
assert_eq!(resolved, None);
assert_eq!(
port.calls.lock().unwrap().as_slice(),
["snapshot:user-a:key-a"]
);
}
}
@@ -0,0 +1,215 @@
use async_trait::async_trait;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum AiSyncDecisionStep {
VideoTaskFollowUp,
LocalVideo,
LocalImage,
LocalOpenAiChat,
LocalOpenAiResponses,
LocalStandardFamily,
LocalSameFormatProvider,
LocalGeminiFiles,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AiStreamDecisionStep {
LocalVideoContent,
LocalImage,
LocalOpenAiChat,
LocalOpenAiResponses,
LocalStandardFamily,
LocalSameFormatProvider,
LocalGeminiFiles,
}
#[async_trait]
pub trait AiSyncDecisionPathPort: Send + Sync {
type Decision: Send;
type Error: Send;
fn sync_decision_step_enabled(&self, step: AiSyncDecisionStep) -> bool;
async fn build_sync_decision_step(
&self,
step: AiSyncDecisionStep,
) -> Result<Option<Self::Decision>, Self::Error>;
}
#[async_trait]
pub trait AiStreamDecisionPathPort: Send + Sync {
type Decision: Send;
type Error: Send;
async fn build_stream_decision_step(
&self,
step: AiStreamDecisionStep,
) -> Result<Option<Self::Decision>, Self::Error>;
}
pub async fn run_ai_sync_decision_path<Port>(
port: &Port,
) -> Result<Option<Port::Decision>, Port::Error>
where
Port: AiSyncDecisionPathPort,
{
for step in [
AiSyncDecisionStep::VideoTaskFollowUp,
AiSyncDecisionStep::LocalVideo,
AiSyncDecisionStep::LocalImage,
AiSyncDecisionStep::LocalOpenAiChat,
AiSyncDecisionStep::LocalOpenAiResponses,
AiSyncDecisionStep::LocalStandardFamily,
AiSyncDecisionStep::LocalSameFormatProvider,
AiSyncDecisionStep::LocalGeminiFiles,
] {
if !port.sync_decision_step_enabled(step) {
continue;
}
if let Some(decision) = port.build_sync_decision_step(step).await? {
return Ok(Some(decision));
}
}
Ok(None)
}
pub async fn run_ai_stream_decision_path<Port>(
port: &Port,
) -> Result<Option<Port::Decision>, Port::Error>
where
Port: AiStreamDecisionPathPort,
{
for step in [
AiStreamDecisionStep::LocalVideoContent,
AiStreamDecisionStep::LocalImage,
AiStreamDecisionStep::LocalOpenAiChat,
AiStreamDecisionStep::LocalOpenAiResponses,
AiStreamDecisionStep::LocalStandardFamily,
AiStreamDecisionStep::LocalSameFormatProvider,
AiStreamDecisionStep::LocalGeminiFiles,
] {
if let Some(decision) = port.build_stream_decision_step(step).await? {
return Ok(Some(decision));
}
}
Ok(None)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::{BTreeSet, VecDeque};
use std::sync::Mutex;
#[derive(Default)]
struct TestSyncDecisionPort {
disabled: BTreeSet<AiSyncDecisionStep>,
outcomes: Mutex<VecDeque<Option<&'static str>>>,
calls: Mutex<Vec<String>>,
}
#[async_trait]
impl AiSyncDecisionPathPort for TestSyncDecisionPort {
type Decision = &'static str;
type Error = std::convert::Infallible;
fn sync_decision_step_enabled(&self, step: AiSyncDecisionStep) -> bool {
!self.disabled.contains(&step)
}
async fn build_sync_decision_step(
&self,
step: AiSyncDecisionStep,
) -> Result<Option<Self::Decision>, Self::Error> {
self.calls.lock().unwrap().push(format!("{step:?}"));
Ok(self.outcomes.lock().unwrap().pop_front().unwrap_or(None))
}
}
#[derive(Default)]
struct TestStreamDecisionPort {
outcomes: Mutex<VecDeque<Option<&'static str>>>,
calls: Mutex<Vec<String>>,
}
#[async_trait]
impl AiStreamDecisionPathPort for TestStreamDecisionPort {
type Decision = &'static str;
type Error = std::convert::Infallible;
async fn build_stream_decision_step(
&self,
step: AiStreamDecisionStep,
) -> Result<Option<Self::Decision>, Self::Error> {
self.calls.lock().unwrap().push(format!("{step:?}"));
Ok(self.outcomes.lock().unwrap().pop_front().unwrap_or(None))
}
}
#[tokio::test]
async fn sync_decision_path_runs_steps_in_serving_order() {
let port = TestSyncDecisionPort::default();
let decision = run_ai_sync_decision_path(&port).await.unwrap();
assert_eq!(decision, None);
assert_eq!(
port.calls.lock().unwrap().as_slice(),
[
"VideoTaskFollowUp",
"LocalVideo",
"LocalImage",
"LocalOpenAiChat",
"LocalOpenAiResponses",
"LocalStandardFamily",
"LocalSameFormatProvider",
"LocalGeminiFiles",
]
);
}
#[tokio::test]
async fn sync_decision_path_skips_disabled_steps_and_stops_at_first_decision() {
let port = TestSyncDecisionPort {
disabled: BTreeSet::from([AiSyncDecisionStep::LocalGeminiFiles]),
outcomes: Mutex::new(VecDeque::from([
None,
None,
Some("image_decision"),
Some("should_not_run"),
])),
calls: Mutex::default(),
};
let decision = run_ai_sync_decision_path(&port).await.unwrap();
assert_eq!(decision, Some("image_decision"));
assert_eq!(
port.calls.lock().unwrap().as_slice(),
["VideoTaskFollowUp", "LocalVideo", "LocalImage"]
);
}
#[tokio::test]
async fn stream_decision_path_stops_at_first_decision() {
let port = TestStreamDecisionPort {
outcomes: Mutex::new(VecDeque::from([
None,
None,
Some("chat_decision"),
Some("should_not_run"),
])),
calls: Mutex::default(),
};
let decision = run_ai_stream_decision_path(&port).await.unwrap();
assert_eq!(decision, Some("chat_decision"));
assert_eq!(
port.calls.lock().unwrap().as_slice(),
["LocalVideoContent", "LocalImage", "LocalOpenAiChat"]
);
}
}
@@ -0,0 +1,304 @@
use std::collections::BTreeMap;
use aether_ai_formats::api::{
ExecutionRuntimeAuthContext, EXECUTION_RUNTIME_STREAM_DECISION_ACTION,
EXECUTION_RUNTIME_SYNC_DECISION_ACTION,
};
use aether_contracts::{
ExecutionTimeouts, ProxySnapshot, ResolvedTransportProfile, TRANSPORT_BACKEND_HYPER_RUSTLS,
TRANSPORT_BACKEND_REQWEST_RUSTLS, TRANSPORT_HTTP_MODE_HTTP1_ONLY,
};
use serde_json::{json, Map, Value};
use crate::{AiExecutionDecision, AiRequestGzipPolicy, ConversionMode, ExecutionStrategy};
pub struct AiExecutionDecisionResponseParts {
pub decision_is_stream: bool,
pub decision_kind: String,
pub execution_strategy: ExecutionStrategy,
pub conversion_mode: ConversionMode,
pub request_id: String,
pub candidate_id: String,
pub provider_name: String,
pub provider_type: String,
pub provider_id: String,
pub endpoint_id: String,
pub key_id: String,
pub upstream_base_url: String,
pub upstream_url: String,
pub provider_request_method: Option<String>,
pub auth_header: Option<String>,
pub auth_value: Option<String>,
pub provider_api_format: String,
pub client_api_format: String,
pub model_name: String,
pub mapped_model: String,
pub prompt_cache_key: Option<String>,
pub provider_request_headers: BTreeMap<String, String>,
pub provider_request_body: Option<serde_json::Value>,
pub provider_request_body_base64: Option<String>,
pub content_type: Option<String>,
pub content_encoding: Option<String>,
pub request_gzip: Option<AiRequestGzipPolicy>,
pub proxy: Option<ProxySnapshot>,
pub transport_profile: Option<ResolvedTransportProfile>,
pub timeouts: Option<ExecutionTimeouts>,
pub upstream_is_stream: bool,
pub report_kind: Option<String>,
pub report_context: Option<serde_json::Value>,
pub auth_context: ExecutionRuntimeAuthContext,
}
pub fn build_ai_execution_decision_response(
mut parts: AiExecutionDecisionResponseParts,
) -> AiExecutionDecision {
parts.report_context = attach_outgoing_tls_fingerprint(
parts.report_context,
parts.transport_profile.as_ref(),
parts.proxy.as_ref(),
);
AiExecutionDecision {
action: ai_execution_decision_action(parts.decision_is_stream).to_string(),
decision_kind: Some(parts.decision_kind),
execution_strategy: Some(parts.execution_strategy.as_str().to_string()),
conversion_mode: Some(parts.conversion_mode.as_str().to_string()),
request_id: Some(parts.request_id),
candidate_id: Some(parts.candidate_id),
provider_name: Some(parts.provider_name),
provider_type: Some(parts.provider_type),
provider_id: Some(parts.provider_id),
endpoint_id: Some(parts.endpoint_id),
key_id: Some(parts.key_id),
upstream_base_url: Some(parts.upstream_base_url),
upstream_url: Some(parts.upstream_url),
provider_request_method: parts.provider_request_method,
auth_header: parts.auth_header,
auth_value: parts.auth_value,
provider_api_format: Some(parts.provider_api_format.clone()),
client_api_format: Some(parts.client_api_format.clone()),
provider_contract: Some(parts.provider_api_format),
client_contract: Some(parts.client_api_format),
model_name: Some(parts.model_name),
mapped_model: Some(parts.mapped_model),
prompt_cache_key: parts.prompt_cache_key,
extra_headers: BTreeMap::new(),
provider_request_headers: parts.provider_request_headers,
provider_request_body: parts.provider_request_body,
provider_request_body_base64: parts.provider_request_body_base64,
content_type: parts.content_type,
content_encoding: parts.content_encoding,
request_gzip: parts.request_gzip,
proxy: parts.proxy,
transport_profile: parts.transport_profile,
timeouts: parts.timeouts,
upstream_is_stream: parts.upstream_is_stream,
report_kind: parts.report_kind,
report_context: parts.report_context,
auth_context: Some(parts.auth_context),
}
}
pub const fn ai_execution_decision_action(decision_is_stream: bool) -> &'static str {
if decision_is_stream {
EXECUTION_RUNTIME_STREAM_DECISION_ACTION
} else {
EXECUTION_RUNTIME_SYNC_DECISION_ACTION
}
}
fn attach_outgoing_tls_fingerprint(
report_context: Option<Value>,
transport_profile: Option<&ResolvedTransportProfile>,
proxy: Option<&ProxySnapshot>,
) -> Option<Value> {
let mut report_context = match report_context {
Some(Value::Object(object)) => object,
_ => Map::new(),
};
let tls_fingerprint = report_context
.entry("tls_fingerprint".to_string())
.or_insert_with(|| Value::Object(Map::new()));
if let Value::Object(object) = tls_fingerprint {
object.insert(
"outgoing".to_string(),
outgoing_tls_fingerprint_value(transport_profile, proxy),
);
}
Some(Value::Object(report_context))
}
fn outgoing_tls_fingerprint_value(
transport_profile: Option<&ResolvedTransportProfile>,
proxy: Option<&ProxySnapshot>,
) -> Value {
let via_local_proxy = proxy
.and_then(|value| value.node_id.as_deref())
.map(str::trim)
.is_some_and(|value| !value.is_empty());
let backend = transport_profile
.map(|profile| profile.backend.trim())
.filter(|value| !value.is_empty())
.unwrap_or(if via_local_proxy {
TRANSPORT_BACKEND_HYPER_RUSTLS
} else {
TRANSPORT_BACKEND_REQWEST_RUSTLS
});
let http_mode = transport_profile
.map(|profile| profile.http_mode.trim())
.filter(|value| !value.is_empty())
.unwrap_or("auto");
let alpn_offered = if http_mode.eq_ignore_ascii_case(TRANSPORT_HTTP_MODE_HTTP1_ONLY) {
json!(["http/1.1"])
} else {
json!(["h2", "http/1.1"])
};
let transport_path = if via_local_proxy {
"aether_proxy_tunnel"
} else if proxy.is_some() {
"direct_with_proxy"
} else {
"direct"
};
let mut object = Map::new();
object.insert(
"source".to_string(),
Value::String("aether_transport_config".to_string()),
);
object.insert("observed".to_string(), Value::Bool(false));
object.insert(
"transport_path".to_string(),
Value::String(transport_path.to_string()),
);
object.insert("backend".to_string(), Value::String(backend.to_string()));
object.insert(
"http_mode".to_string(),
Value::String(http_mode.to_string()),
);
object.insert("tls_stack".to_string(), Value::String("rustls".to_string()));
object.insert(
"tls_versions_offered".to_string(),
json!(["TLS1.3", "TLS1.2"]),
);
object.insert("alpn_offered".to_string(), alpn_offered);
if let Some(profile) = transport_profile {
object.insert(
"profile_id".to_string(),
Value::String(profile.profile_id.clone()),
);
object.insert(
"pool_scope".to_string(),
Value::String(profile.pool_scope.clone()),
);
}
Value::Object(object)
}
#[cfg(test)]
mod tests {
use super::*;
use aether_contracts::{TRANSPORT_HTTP_MODE_AUTO, TRANSPORT_POOL_SCOPE_KEY};
fn sample_parts() -> AiExecutionDecisionResponseParts {
AiExecutionDecisionResponseParts {
decision_is_stream: false,
decision_kind: "local_sync".to_string(),
execution_strategy: ExecutionStrategy::LocalSameFormat,
conversion_mode: ConversionMode::None,
request_id: "trace-1".to_string(),
candidate_id: "candidate-1".to_string(),
provider_name: "OpenAI".to_string(),
provider_type: "openai".to_string(),
provider_id: "provider-1".to_string(),
endpoint_id: "endpoint-1".to_string(),
key_id: "key-1".to_string(),
upstream_base_url: "https://api.example.com".to_string(),
upstream_url: "https://api.example.com/v1/chat/completions".to_string(),
provider_request_method: None,
auth_header: None,
auth_value: None,
provider_api_format: "openai:chat".to_string(),
client_api_format: "openai:chat".to_string(),
model_name: "gpt-5".to_string(),
mapped_model: "gpt-5".to_string(),
prompt_cache_key: None,
provider_request_headers: BTreeMap::new(),
provider_request_body: Some(json!({"model": "gpt-5"})),
provider_request_body_base64: None,
content_type: Some("application/json".to_string()),
content_encoding: None,
request_gzip: None,
proxy: None,
transport_profile: None,
timeouts: None,
upstream_is_stream: false,
report_kind: None,
report_context: Some(json!({
"tls_fingerprint": {
"incoming": {
"source": "forwarded_header",
"ja3": "incoming-ja3"
}
}
})),
auth_context: ExecutionRuntimeAuthContext {
user_id: "user-1".to_string(),
api_key_id: "api-key-1".to_string(),
username: None,
api_key_name: None,
balance_remaining: None,
access_allowed: true,
api_key_is_standalone: false,
},
}
}
#[test]
fn decision_response_records_outgoing_tls_fingerprint_without_dropping_incoming() {
let mut parts = sample_parts();
parts.transport_profile = Some(ResolvedTransportProfile {
profile_id: "claude_code_nodejs".to_string(),
backend: TRANSPORT_BACKEND_REQWEST_RUSTLS.to_string(),
http_mode: TRANSPORT_HTTP_MODE_AUTO.to_string(),
pool_scope: TRANSPORT_POOL_SCOPE_KEY.to_string(),
header_fingerprint: None,
extra: None,
});
let decision = build_ai_execution_decision_response(parts);
let tls_fingerprint = &decision.report_context.unwrap()["tls_fingerprint"];
assert_eq!(tls_fingerprint["incoming"]["ja3"], "incoming-ja3");
assert_eq!(
tls_fingerprint["outgoing"]["profile_id"],
"claude_code_nodejs"
);
assert_eq!(
tls_fingerprint["outgoing"]["backend"],
TRANSPORT_BACKEND_REQWEST_RUSTLS
);
assert_eq!(tls_fingerprint["outgoing"]["observed"], false);
assert_eq!(
tls_fingerprint["outgoing"]["alpn_offered"],
json!(["h2", "http/1.1"])
);
}
#[test]
fn decision_response_defaults_outgoing_tls_to_proxy_hyper_backend_for_node_proxy() {
let mut parts = sample_parts();
parts.proxy = Some(ProxySnapshot {
node_id: Some("proxy-node-1".to_string()),
..ProxySnapshot::default()
});
let decision = build_ai_execution_decision_response(parts);
let outgoing = &decision.report_context.unwrap()["tls_fingerprint"]["outgoing"];
assert_eq!(outgoing["transport_path"], "aether_proxy_tunnel");
assert_eq!(outgoing["backend"], TRANSPORT_BACKEND_HYPER_RUSTLS);
}
}
+267
View File
@@ -0,0 +1,267 @@
use std::collections::BTreeMap;
use aether_ai_formats::api::ExecutionRuntimeAuthContext;
use aether_contracts::{ExecutionPlan, ExecutionTimeouts, ProxySnapshot, ResolvedTransportProfile};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExecutionStrategy {
GatewayAffinityForward,
RawPublicProxy,
LocalSameFormat,
LocalCrossFormat,
}
impl ExecutionStrategy {
pub const fn as_str(self) -> &'static str {
match self {
Self::GatewayAffinityForward => "gateway_affinity_forward",
Self::RawPublicProxy => "raw_public_proxy",
Self::LocalSameFormat => "local_same_format",
Self::LocalCrossFormat => "local_cross_format",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConversionMode {
None,
RequestOnly,
ResponseOnly,
Bidirectional,
}
impl ConversionMode {
pub const fn as_str(self) -> &'static str {
match self {
Self::None => "none",
Self::RequestOnly => "request_only",
Self::ResponseOnly => "response_only",
Self::Bidirectional => "bidirectional",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct AiRequestGzipPolicy {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min_bytes: Option<usize>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct AiExecutionPlanPayload {
pub action: String,
#[serde(default)]
pub plan_kind: Option<String>,
#[serde(default)]
pub plan: Option<ExecutionPlan>,
#[serde(default)]
pub report_kind: Option<String>,
#[serde(default)]
pub report_context: Option<serde_json::Value>,
#[serde(default)]
pub auth_context: Option<ExecutionRuntimeAuthContext>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct AiExecutionDecision {
pub action: String,
#[serde(default)]
pub decision_kind: Option<String>,
#[serde(default)]
pub execution_strategy: Option<String>,
#[serde(default)]
pub conversion_mode: Option<String>,
#[serde(default)]
pub request_id: Option<String>,
#[serde(default)]
pub candidate_id: Option<String>,
#[serde(default)]
pub provider_name: Option<String>,
#[serde(default)]
pub provider_type: Option<String>,
#[serde(default)]
pub provider_id: Option<String>,
#[serde(default)]
pub endpoint_id: Option<String>,
#[serde(default)]
pub key_id: Option<String>,
#[serde(default)]
pub upstream_base_url: Option<String>,
#[serde(default)]
pub upstream_url: Option<String>,
#[serde(default)]
pub provider_request_method: Option<String>,
#[serde(default)]
pub auth_header: Option<String>,
#[serde(default)]
pub auth_value: Option<String>,
#[serde(default)]
pub provider_api_format: Option<String>,
#[serde(default)]
pub client_api_format: Option<String>,
#[serde(default)]
pub provider_contract: Option<String>,
#[serde(default)]
pub client_contract: Option<String>,
#[serde(default)]
pub model_name: Option<String>,
#[serde(default)]
pub mapped_model: Option<String>,
#[serde(default)]
pub prompt_cache_key: Option<String>,
#[serde(default)]
pub extra_headers: BTreeMap<String, String>,
#[serde(default)]
pub provider_request_headers: BTreeMap<String, String>,
#[serde(default)]
pub provider_request_body: Option<serde_json::Value>,
#[serde(default)]
pub provider_request_body_base64: Option<String>,
#[serde(default)]
pub content_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content_encoding: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_gzip: Option<AiRequestGzipPolicy>,
#[serde(default)]
pub proxy: Option<ProxySnapshot>,
#[serde(default)]
pub transport_profile: Option<ResolvedTransportProfile>,
#[serde(default)]
pub timeouts: Option<ExecutionTimeouts>,
#[serde(default)]
pub upstream_is_stream: bool,
#[serde(default)]
pub report_kind: Option<String>,
#[serde(default)]
pub report_context: Option<serde_json::Value>,
#[serde(default)]
pub auth_context: Option<ExecutionRuntimeAuthContext>,
}
#[derive(Debug)]
pub struct AiSyncAttempt {
pub plan: ExecutionPlan,
pub report_kind: Option<String>,
pub report_context: Option<serde_json::Value>,
}
#[derive(Debug)]
pub struct AiStreamAttempt {
pub plan: ExecutionPlan,
pub report_kind: Option<String>,
pub report_context: Option<serde_json::Value>,
}
pub fn augment_sync_report_context(
report_context: Option<serde_json::Value>,
provider_request_headers: &BTreeMap<String, String>,
_provider_request_body: &serde_json::Value,
) -> serde_json::Result<Option<serde_json::Value>> {
let mut report_context = match report_context {
Some(serde_json::Value::Object(map)) => map,
Some(_) => serde_json::Map::new(),
None => serde_json::Map::new(),
};
report_context.insert(
"provider_request_headers".to_string(),
serde_json::to_value(provider_request_headers)?,
);
Ok(Some(serde_json::Value::Object(report_context)))
}
fn decision_has_exact_provider_request(payload: &AiExecutionDecision) -> bool {
!payload.provider_request_headers.is_empty()
&& (payload.provider_request_body.is_some()
|| payload
.provider_request_body_base64
.as_ref()
.map(|value| !value.trim().is_empty())
.unwrap_or(false))
}
pub fn generic_decision_missing_exact_provider_request(payload: &AiExecutionDecision) -> bool {
!decision_has_exact_provider_request(payload)
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::{
augment_sync_report_context, generic_decision_missing_exact_provider_request,
AiExecutionDecision,
};
#[test]
fn generic_decision_detects_missing_exact_provider_request() {
let payload = AiExecutionDecision {
action: "local".to_string(),
decision_kind: Some("sync".to_string()),
execution_strategy: None,
conversion_mode: None,
request_id: None,
candidate_id: None,
provider_name: None,
provider_type: None,
provider_id: None,
endpoint_id: None,
key_id: None,
upstream_base_url: None,
upstream_url: None,
provider_request_method: None,
auth_header: None,
auth_value: None,
provider_api_format: None,
client_api_format: None,
provider_contract: None,
client_contract: None,
model_name: None,
mapped_model: None,
prompt_cache_key: None,
extra_headers: Default::default(),
provider_request_headers: Default::default(),
provider_request_body: None,
provider_request_body_base64: None,
content_type: None,
content_encoding: None,
request_gzip: None,
proxy: None,
transport_profile: None,
timeouts: None,
upstream_is_stream: false,
report_kind: None,
report_context: None,
auth_context: None,
};
assert!(generic_decision_missing_exact_provider_request(&payload));
}
#[test]
fn augment_sync_report_context_attaches_provider_request_headers_only() {
let report_context = augment_sync_report_context(
Some(serde_json::json!({"trace_id": "abc"})),
&BTreeMap::from([("content-type".to_string(), "application/json".to_string())]),
&serde_json::json!({"model": "gpt-5"}),
)
.expect("context should serialize")
.expect("context should exist");
assert_eq!(
report_context["provider_request_headers"]["content-type"],
"application/json"
);
assert!(
report_context.get("provider_request_body").is_none(),
"provider request body should not be copied into report context"
);
}
}
@@ -0,0 +1,486 @@
use async_trait::async_trait;
#[derive(Debug)]
pub enum AiServingExecutionOutcome<Response, Exhaustion> {
Responded(Response),
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,
}
pub const DEFAULT_STREAM_EXECUTION_STEPS: &[AiStreamExecutionStep] = &[
AiStreamExecutionStep::LocalVideoContent,
AiStreamExecutionStep::LocalImage,
AiStreamExecutionStep::LocalOpenAiChat,
AiStreamExecutionStep::LocalOpenAiResponses,
AiStreamExecutionStep::LocalStandardFamily,
AiStreamExecutionStep::LocalSameFormatProvider,
AiStreamExecutionStep::LocalGeminiFiles,
AiStreamExecutionStep::RemoteDecision,
];
#[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;
fn stream_execution_steps(&self) -> &'static [AiStreamExecutionStep] {
DEFAULT_STREAM_EXECUTION_STEPS
}
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;
if let Some(response) =
absorb_sync_step(port, AiSyncExecutionStep::VideoTaskFollowUp, &mut exhausted).await?
{
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 exhausted).await? {
return Ok(response);
}
}
}
if let Some(outcome) = exhausted {
return Ok(AiServingExecutionOutcome::Exhausted(outcome));
}
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::Exhausted(outcome) => {
Ok(AiServingExecutionOutcome::Exhausted(outcome))
}
AiServingExecutionOutcome::NoPath => Ok(AiServingExecutionOutcome::NoPath),
}
}
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;
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 exhausted).await? {
return Ok(response);
}
}
if let Some(outcome) = exhausted {
return Ok(AiServingExecutionOutcome::Exhausted(outcome));
}
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::Exhausted(outcome) => {
Ok(AiServingExecutionOutcome::Exhausted(outcome))
}
AiServingExecutionOutcome::NoPath => Ok(AiServingExecutionOutcome::NoPath),
}
}
async fn absorb_sync_step<Port>(
port: &Port,
step: AiSyncExecutionStep,
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::Exhausted(outcome) => {
*exhausted = Some(outcome);
Ok(None)
}
AiServingExecutionOutcome::NoPath => Ok(None),
}
}
async fn absorb_stream_step<Port>(
port: &Port,
step: AiStreamExecutionStep,
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::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,
stream_steps: Option<&'static [AiStreamExecutionStep]>,
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
}
fn stream_execution_steps(&self) -> &'static [AiStreamExecutionStep] {
self.stream_steps
.unwrap_or(super::DEFAULT_STREAM_EXECUTION_STEPS)
}
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")
));
assert_eq!(
port.calls.lock().unwrap().as_slice(),
[
"VideoTaskFollowUp",
"LocalVideo",
"LocalImage",
"LocalOpenAiChat",
"LocalOpenAiResponses",
"LocalStandardFamily",
"LocalSameFormatProvider",
"LocalGeminiFiles",
"RemoteDecision",
]
);
}
#[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,
stream_steps: None,
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"]
);
}
#[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_last_exhaustion_without_plan_fallback() {
let port = TestStreamPort {
scheduler_supported: true,
stream_steps: None,
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",
]
);
}
}
@@ -0,0 +1,224 @@
use serde_json::{json, Value};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CandidateFailureDiagnosticKind {
RequestBodyBuild,
RequestConversion,
BodyRules,
HeaderRules,
UrlBuild,
TransportAuth,
EnvelopeBuild,
}
impl CandidateFailureDiagnosticKind {
pub const fn as_str(self) -> &'static str {
match self {
Self::RequestBodyBuild => "request_body_build",
Self::RequestConversion => "request_conversion",
Self::BodyRules => "body_rules",
Self::HeaderRules => "header_rules",
Self::UrlBuild => "url_build",
Self::TransportAuth => "transport_auth",
Self::EnvelopeBuild => "envelope_build",
}
}
}
#[derive(Debug, Clone)]
pub struct CandidateFailureDiagnostic {
kind: CandidateFailureDiagnosticKind,
path: String,
message: String,
source: Option<String>,
client_api_format: Option<String>,
provider_api_format: Option<String>,
safe_to_show: bool,
}
impl CandidateFailureDiagnostic {
pub fn new(
kind: CandidateFailureDiagnosticKind,
path: impl Into<String>,
message: impl Into<String>,
) -> Self {
Self {
kind,
path: path.into(),
message: message.into(),
source: None,
client_api_format: None,
provider_api_format: None,
safe_to_show: true,
}
}
pub fn source(mut self, source: impl Into<String>) -> Self {
self.source = Some(source.into());
self
}
pub fn formats(
mut self,
client_api_format: impl Into<String>,
provider_api_format: impl Into<String>,
) -> Self {
self.client_api_format = Some(client_api_format.into());
self.provider_api_format = Some(provider_api_format.into());
self
}
pub fn has_specific_path(&self) -> bool {
let path = self.path.trim();
!path.is_empty() && path != "$"
}
pub fn to_extra_data(&self) -> Value {
let diagnostic = self.to_value();
let mut extra_data = json!({
"failure_diagnostic": diagnostic,
});
// Compatibility for current usage UI and already persisted trace readers.
if let Some(object) = extra_data.as_object_mut() {
match self.kind {
CandidateFailureDiagnosticKind::RequestBodyBuild => {
object.insert(
"request_body_build_error".to_string(),
json!({
"path": self.path,
"message": self.message,
"client_api_format": self.client_api_format,
"provider_api_format": self.provider_api_format,
}),
);
}
CandidateFailureDiagnosticKind::RequestConversion => {
object.insert(
"request_conversion_error".to_string(),
json!({
"path": self.path,
"message": self.message,
"client_api_format": self.client_api_format,
"provider_api_format": self.provider_api_format,
}),
);
}
_ => {}
}
}
extra_data
}
pub fn upstream_url_missing(
client_api_format: impl Into<String>,
provider_api_format: impl Into<String>,
source: impl Into<String>,
) -> Self {
Self::new(
CandidateFailureDiagnosticKind::UrlBuild,
"$.endpoint",
"无法构建上游请求地址;请检查 base_url、custom_path、API 格式和模型映射",
)
.formats(client_api_format, provider_api_format)
.source(source)
}
pub fn header_rules_apply_failed(
client_api_format: impl Into<String>,
provider_api_format: impl Into<String>,
source: impl Into<String>,
) -> Self {
Self::new(
CandidateFailureDiagnosticKind::HeaderRules,
"$.endpoint.header_rules",
"Header 规则应用失败;请检查规则格式、条件配置,或是否试图覆盖受保护认证头",
)
.formats(client_api_format, provider_api_format)
.source(source)
}
pub fn body_rules_apply_failed(
client_api_format: impl Into<String>,
provider_api_format: impl Into<String>,
source: impl Into<String>,
) -> Self {
Self::new(
CandidateFailureDiagnosticKind::BodyRules,
"$.endpoint.body_rules",
"Body 规则应用失败;请检查规则格式、条件配置,或规则输出是否仍是当前上游支持的请求体",
)
.formats(client_api_format, provider_api_format)
.source(source)
}
pub fn body_rules_unsupported_for_binary_upload(
client_api_format: impl Into<String>,
provider_api_format: impl Into<String>,
source: impl Into<String>,
) -> Self {
Self::new(
CandidateFailureDiagnosticKind::BodyRules,
"$.endpoint.body_rules",
"二进制上传暂不支持本地应用 Body 规则;请移除该 Endpoint 的 Body 规则或改用 JSON 请求体",
)
.formats(client_api_format, provider_api_format)
.source(source)
}
pub fn provider_request_body_missing(
client_api_format: impl Into<String>,
provider_api_format: impl Into<String>,
source: impl Into<String>,
) -> Self {
Self::new(
CandidateFailureDiagnosticKind::RequestBodyBuild,
"$",
"无法构建上游请求体;请检查请求体是否为支持的 JSON object,以及该任务类型必需字段是否存在且取值受支持",
)
.formats(client_api_format, provider_api_format)
.source(source)
}
pub fn request_conversion_failed(
client_api_format: impl Into<String>,
provider_api_format: impl Into<String>,
source: impl Into<String>,
message: impl Into<String>,
) -> Self {
Self::new(
CandidateFailureDiagnosticKind::RequestConversion,
"$",
message,
)
.formats(client_api_format, provider_api_format)
.source(source)
}
pub fn envelope_build_failed(
client_api_format: impl Into<String>,
provider_api_format: impl Into<String>,
source: impl Into<String>,
) -> Self {
Self::new(
CandidateFailureDiagnosticKind::EnvelopeBuild,
"$",
"无法构建上游请求封装;请检查该 Provider 的认证配置、模型映射、Endpoint Body 规则和当前请求体是否兼容",
)
.formats(client_api_format, provider_api_format)
.source(source)
}
fn to_value(&self) -> Value {
json!({
"kind": self.kind.as_str(),
"path": self.path,
"message": self.message,
"source": self.source,
"client_api_format": self.client_api_format,
"provider_api_format": self.provider_api_format,
"safe_to_show": self.safe_to_show,
})
}
}
+152
View File
@@ -0,0 +1,152 @@
pub mod attempt_loop;
pub mod attempt_plan;
pub mod candidate_materialization;
pub mod candidate_metadata;
pub mod candidate_persistence;
pub mod candidate_persistence_policy;
pub mod candidate_preparation;
pub mod candidate_preselection;
pub mod candidate_ranking;
pub mod candidate_resolution;
pub mod decision_input;
pub mod decision_path;
pub mod decision_payload;
pub mod dto;
pub mod execution_path;
pub mod failure_diagnostic;
pub mod plan_payload;
pub mod ports;
pub mod ranking_metadata;
pub mod report_context;
pub mod request_body_diagnostics;
pub mod runtime_miss;
pub mod surface_spec;
pub use aether_ai_formats::UPSTREAM_IS_STREAM_KEY;
pub use aether_pool_core::{
normalize_enabled_pool_presets, run_pool_scheduler, PoolCandidateFacts, PoolCandidateInput,
PoolCandidateOrchestration, PoolMemberSignals, PoolRuntimeState, PoolScheduledCandidate,
PoolSchedulerOutcome, PoolSchedulingConfig, PoolSchedulingPreset, PoolSkippedCandidate,
POOL_ACCOUNT_BLOCKED_SKIP_REASON, POOL_ACCOUNT_EXHAUSTED_SKIP_REASON,
POOL_COOLDOWN_SKIP_REASON, POOL_COST_LIMIT_REACHED_SKIP_REASON,
};
pub use aether_pool_core::{
normalize_enabled_pool_presets as normalize_enabled_ai_pool_presets,
run_pool_scheduler as run_ai_pool_scheduler, PoolCandidateFacts as AiPoolCandidateFacts,
PoolCandidateInput as AiPoolCandidateInput,
PoolCandidateOrchestration as AiPoolCandidateOrchestration,
PoolMemberSignals as AiPoolCatalogKeyContext, PoolRuntimeState as AiPoolRuntimeState,
PoolScheduledCandidate as AiPoolScheduledCandidate,
PoolSchedulerOutcome as AiPoolSchedulerOutcome, PoolSchedulingConfig as AiPoolSchedulingConfig,
PoolSchedulingPreset as AiPoolSchedulingPreset, PoolSkippedCandidate as AiPoolSkippedCandidate,
POOL_ACCOUNT_BLOCKED_SKIP_REASON as AI_POOL_ACCOUNT_BLOCKED_SKIP_REASON,
POOL_ACCOUNT_EXHAUSTED_SKIP_REASON as AI_POOL_ACCOUNT_EXHAUSTED_SKIP_REASON,
POOL_COOLDOWN_SKIP_REASON as AI_POOL_COOLDOWN_SKIP_REASON,
POOL_COST_LIMIT_REACHED_SKIP_REASON as AI_POOL_COST_LIMIT_REACHED_SKIP_REASON,
};
pub use aether_pool_core::{
probe_freshness_score, probe_freshness_score_with_ttl, score_pool_member,
score_pool_member_with_rules, PoolMemberScoreInput, PoolMemberScoreOutput,
PoolMemberScoreRules, PoolMemberScoreWeights, POOL_SCORE_VERSION,
PROBE_FAILURE_COOLDOWN_THRESHOLD, PROBE_FAILURE_PENALTY, PROBE_FRESHNESS_TTL_SECONDS,
REQUEST_FAILURE_PENALTY, UNSCHEDULABLE_SCORE_CAP,
};
pub use attempt_loop::{
run_ai_attempt_loop, AiAttemptLoopOutcome, AiAttemptLoopPort, AiExecutionAttempt,
};
pub use attempt_plan::{
build_ai_execution_decision_from_plan, build_ai_execution_plan_from_decision,
extract_ai_auth_header_pair, infer_ai_upstream_base_url,
resolve_ai_passthrough_sync_request_body, take_ai_decision_plan_core, take_ai_non_empty_string,
take_ai_upstream_auth_pair, trim_ai_owned_non_empty_string, AiDecisionPlanCore,
AiExecutionDecisionFromPlanParts, AiExecutionPlanFromDecisionParts, AiUpstreamAuthPair,
};
pub use candidate_materialization::{
run_ai_candidate_materialization, AiCandidateMaterializationOutcome,
AiCandidateMaterializationPort,
};
pub use candidate_metadata::{
ai_local_execution_contract_for_formats, append_ai_execution_contract_fields_to_value,
build_ai_candidate_metadata, build_ai_candidate_metadata_from_candidate,
AiCandidateMetadataParts,
};
pub use candidate_persistence::{
ai_candidate_extra_data_with_ranking, ai_should_persist_available_candidate_for_pool_key,
ai_should_persist_skipped_candidate_for_pool_membership,
run_ai_available_candidate_persistence, run_ai_skipped_candidate_persistence,
AiAvailableCandidatePersistencePort, AiSkippedCandidatePersistencePort,
};
pub use candidate_persistence_policy::{
ai_candidate_persistence_policy_spec, AiCandidatePersistencePolicyKind,
AiCandidatePersistencePolicySpec,
};
pub use candidate_preparation::{
prepare_ai_header_authenticated_candidate, resolve_ai_candidate_mapped_model,
AiPreparedHeaderAuthenticatedCandidate,
};
pub use candidate_preselection::{
run_ai_candidate_preselection, AiCandidatePreselectionOutcome, AiCandidatePreselectionPort,
};
pub use candidate_ranking::{
ai_ranking_context, build_ai_rankable_candidate, run_ai_candidate_ranking,
AiCandidateRankingPort, AiRankableCandidateParts, AiRankingContextConfig,
AiRankingSchedulingMode,
};
pub use candidate_resolution::{
extract_ai_pool_sticky_session_token, run_ai_candidate_resolution, AiCandidateResolutionMode,
AiCandidateResolutionOutcome, AiCandidateResolutionPort, AiCandidateResolutionRequest,
};
pub use decision_input::{run_ai_authenticated_decision_input, AiAuthenticatedDecisionInputPort};
pub use decision_path::{
run_ai_stream_decision_path, run_ai_sync_decision_path, AiStreamDecisionPathPort,
AiStreamDecisionStep, AiSyncDecisionPathPort, AiSyncDecisionStep,
};
pub use decision_payload::{
ai_execution_decision_action, build_ai_execution_decision_response,
AiExecutionDecisionResponseParts,
};
pub use dto::{
augment_sync_report_context, generic_decision_missing_exact_provider_request,
AiExecutionDecision, AiExecutionPlanPayload, AiRequestGzipPolicy, AiStreamAttempt,
AiSyncAttempt, ConversionMode, ExecutionStrategy,
};
pub use execution_path::{
run_ai_stream_execution_path, run_ai_sync_execution_path, AiPlanFallbackReason,
AiServingExecutionOutcome, AiStreamExecutionPathPort, AiStreamExecutionStep,
AiSyncExecutionPathPort, AiSyncExecutionStep, DEFAULT_STREAM_EXECUTION_STEPS,
};
pub use failure_diagnostic::{CandidateFailureDiagnostic, CandidateFailureDiagnosticKind};
pub use plan_payload::{
build_ai_stream_execution_plan_payload, build_ai_sync_execution_plan_payload,
};
pub use ranking_metadata::append_ai_ranking_metadata_to_object;
pub use report_context::{
build_ai_execution_report_context, build_ai_report_context_original_request_echo,
insert_provider_stream_event_api_format, provider_stream_event_api_format_for_provider_type,
AiExecutionReportContextParts, AiRequestOrigin,
};
pub use request_body_diagnostics::{
request_body_build_failure_extra_data, request_conversion_failure_extra_data,
same_format_provider_request_body_failure_extra_data,
};
pub use runtime_miss::{
apply_ai_runtime_candidate_evaluation_progress,
apply_ai_runtime_candidate_evaluation_progress_preserving_candidate_signal,
apply_ai_runtime_candidate_evaluation_progress_to_diagnostic,
apply_ai_runtime_candidate_terminal_plan_reason_to_diagnostic,
apply_ai_runtime_candidate_terminal_reason, build_ai_runtime_candidate_evaluation_diagnostic,
build_ai_runtime_execution_exhausted_diagnostic, record_ai_runtime_candidate_skip_reason,
record_ai_runtime_candidate_skip_reason_on_diagnostic,
set_ai_runtime_candidate_evaluation_diagnostic, set_ai_runtime_execution_exhausted_diagnostic,
set_ai_runtime_miss_diagnostic_reason, AiRuntimeMissDiagnosticFields,
AiRuntimeMissDiagnosticPort,
};
pub use surface_spec::{
ai_gemini_files_spec_metadata, ai_openai_image_spec_metadata,
ai_openai_responses_spec_metadata, ai_requested_model_family_for_same_format_provider,
ai_requested_model_family_for_standard_source, ai_requested_model_family_for_video_create,
ai_same_format_provider_spec_metadata, ai_standard_spec_metadata,
ai_video_create_spec_metadata, extract_ai_gemini_model_from_path,
extract_ai_requested_model_from_request_path, extract_ai_standard_requested_model,
AiExecutionSurfaceSpecMetadata, AiRequestedModelFamily,
};
@@ -0,0 +1,108 @@
use aether_ai_formats::api::{
ExecutionRuntimeAuthContext, EXECUTION_RUNTIME_STREAM_ACTION, EXECUTION_RUNTIME_SYNC_ACTION,
};
use crate::dto::{AiExecutionPlanPayload, AiStreamAttempt, AiSyncAttempt};
pub fn build_ai_sync_execution_plan_payload(
plan_kind: &str,
attempt: AiSyncAttempt,
auth_context: Option<ExecutionRuntimeAuthContext>,
) -> AiExecutionPlanPayload {
AiExecutionPlanPayload {
action: EXECUTION_RUNTIME_SYNC_ACTION.to_string(),
plan_kind: Some(plan_kind.to_string()),
plan: Some(attempt.plan),
report_kind: attempt.report_kind,
report_context: attempt.report_context,
auth_context,
}
}
pub fn build_ai_stream_execution_plan_payload(
plan_kind: &str,
attempt: AiStreamAttempt,
auth_context: Option<ExecutionRuntimeAuthContext>,
) -> AiExecutionPlanPayload {
AiExecutionPlanPayload {
action: EXECUTION_RUNTIME_STREAM_ACTION.to_string(),
plan_kind: Some(plan_kind.to_string()),
plan: Some(attempt.plan),
report_kind: attempt.report_kind,
report_context: attempt.report_context,
auth_context,
}
}
#[cfg(test)]
mod tests {
use super::*;
use aether_contracts::{ExecutionPlan, RequestBody};
#[test]
fn sync_plan_payload_uses_sync_action_and_attempt_report_fields() {
let payload = build_ai_sync_execution_plan_payload(
"openai_chat_sync",
AiSyncAttempt {
plan: test_plan(),
report_kind: Some("sync_success".to_string()),
report_context: Some(serde_json::json!({"candidate_index": 0})),
},
None,
);
assert_eq!(payload.action, EXECUTION_RUNTIME_SYNC_ACTION);
assert_eq!(payload.plan_kind.as_deref(), Some("openai_chat_sync"));
assert_eq!(payload.report_kind.as_deref(), Some("sync_success"));
assert_eq!(
payload
.report_context
.as_ref()
.and_then(|value| value.get("candidate_index"))
.and_then(serde_json::Value::as_u64),
Some(0)
);
assert!(payload.plan.is_some());
}
#[test]
fn stream_plan_payload_uses_stream_action() {
let payload = build_ai_stream_execution_plan_payload(
"openai_chat_stream",
AiStreamAttempt {
plan: test_plan(),
report_kind: None,
report_context: None,
},
None,
);
assert_eq!(payload.action, EXECUTION_RUNTIME_STREAM_ACTION);
assert_eq!(payload.plan_kind.as_deref(), Some("openai_chat_stream"));
assert!(payload.plan.is_some());
}
fn test_plan() -> ExecutionPlan {
ExecutionPlan {
request_id: "req_1".to_string(),
candidate_id: None,
provider_name: Some("provider".to_string()),
provider_id: "provider_id".to_string(),
endpoint_id: "endpoint_id".to_string(),
key_id: "key_id".to_string(),
method: "POST".to_string(),
url: "https://example.com/v1/chat/completions".to_string(),
headers: Default::default(),
content_type: Some("application/json".to_string()),
content_encoding: None,
body: RequestBody::from_json(serde_json::json!({"model": "model"})),
stream: false,
client_api_format: "openai:chat".to_string(),
provider_api_format: "openai:chat".to_string(),
model_name: Some("model".to_string()),
proxy: None,
transport_profile: None,
timeouts: None,
}
}
}
+12
View File
@@ -0,0 +1,12 @@
pub use crate::attempt_loop::AiAttemptLoopPort;
pub use crate::candidate_materialization::AiCandidateMaterializationPort;
pub use crate::candidate_persistence::{
AiAvailableCandidatePersistencePort, AiSkippedCandidatePersistencePort,
};
pub use crate::candidate_preselection::AiCandidatePreselectionPort;
pub use crate::candidate_ranking::AiCandidateRankingPort;
pub use crate::candidate_resolution::AiCandidateResolutionPort;
pub use crate::decision_input::AiAuthenticatedDecisionInputPort;
pub use crate::decision_path::{AiStreamDecisionPathPort, AiSyncDecisionPathPort};
pub use crate::execution_path::{AiStreamExecutionPathPort, AiSyncExecutionPathPort};
pub use crate::runtime_miss::AiRuntimeMissDiagnosticPort;
@@ -0,0 +1,66 @@
use aether_scheduler_core::SchedulerRankingOutcome;
use serde_json::{Map, Value};
pub fn append_ai_ranking_metadata_to_object(
object: &mut Map<String, Value>,
ranking: &SchedulerRankingOutcome,
) {
object.insert(
"ranking_mode".to_string(),
Value::String(format!("{:?}", ranking.ranking_mode)),
);
object.insert(
"priority_mode".to_string(),
Value::String(format!("{:?}", ranking.priority_mode)),
);
object.insert(
"ranking_index".to_string(),
Value::Number(serde_json::Number::from(ranking.ranking_index as u64)),
);
object.insert(
"priority_slot".to_string(),
Value::Number(serde_json::Number::from(i64::from(ranking.priority_slot))),
);
if let Some(promoted_by) = ranking.promoted_by {
object.insert(
"promoted_by".to_string(),
Value::String(promoted_by.to_string()),
);
}
if let Some(demoted_by) = ranking.demoted_by {
object.insert(
"demoted_by".to_string(),
Value::String(demoted_by.to_string()),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use aether_scheduler_core::{SchedulerPriorityMode, SchedulerRankingMode};
use serde_json::json;
#[test]
fn ranking_metadata_appends_scheduler_outcome_fields() {
let ranking = SchedulerRankingOutcome {
original_index: 2,
ranking_index: 1,
priority_mode: SchedulerPriorityMode::Provider,
ranking_mode: SchedulerRankingMode::CacheAffinity,
priority_slot: 7,
promoted_by: Some("cached_affinity"),
demoted_by: Some("cross_format"),
};
let mut object = Map::new();
append_ai_ranking_metadata_to_object(&mut object, &ranking);
assert_eq!(object.get("ranking_mode"), Some(&json!("CacheAffinity")));
assert_eq!(object.get("priority_mode"), Some(&json!("Provider")));
assert_eq!(object.get("ranking_index"), Some(&json!(1)));
assert_eq!(object.get("priority_slot"), Some(&json!(7)));
assert_eq!(object.get("promoted_by"), Some(&json!("cached_affinity")));
assert_eq!(object.get("demoted_by"), Some(&json!("cross_format")));
}
}
@@ -0,0 +1,389 @@
use std::collections::BTreeMap;
use aether_ai_formats::api::ExecutionRuntimeAuthContext;
use aether_ai_formats::UPSTREAM_IS_STREAM_KEY;
use aether_scheduler_core::SchedulerRankingOutcome;
use serde_json::{Map, Value};
use crate::append_ai_ranking_metadata_to_object;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AiRequestOrigin {
pub client_ip: Option<String>,
pub user_agent: Option<String>,
}
pub struct AiExecutionReportContextParts<'a> {
pub auth_context: &'a ExecutionRuntimeAuthContext,
pub request_id: &'a str,
pub candidate_id: &'a str,
pub candidate_index: u32,
pub retry_index: u32,
pub pool_key_index: Option<u32>,
pub model: &'a str,
pub provider_name: &'a str,
pub provider_id: &'a str,
pub endpoint_id: &'a str,
pub key_id: &'a str,
pub key_name: Option<&'a str>,
pub model_id: Option<&'a str>,
pub global_model_id: Option<&'a str>,
pub global_model_name: Option<&'a str>,
pub provider_api_format: &'a str,
pub client_api_format: &'a str,
pub mapped_model: Option<&'a str>,
pub candidate_group_id: Option<&'a str>,
pub ranking: Option<&'a SchedulerRankingOutcome>,
pub upstream_url: Option<&'a str>,
pub header_rules: Option<&'a Value>,
pub body_rules: Option<&'a Value>,
pub provider_request_method: Option<Value>,
pub provider_request_headers: Option<&'a BTreeMap<String, String>>,
pub original_headers: &'a BTreeMap<String, String>,
pub original_request_body: Option<Value>,
pub request_origin: AiRequestOrigin,
pub client_requested_stream: bool,
pub upstream_is_stream: bool,
pub has_envelope: bool,
pub needs_conversion: bool,
pub extra_fields: Map<String, Value>,
}
pub fn build_ai_execution_report_context(parts: AiExecutionReportContextParts<'_>) -> Value {
let mut object = Map::new();
object.insert(
"user_id".to_string(),
Value::String(parts.auth_context.user_id.clone()),
);
object.insert(
"api_key_id".to_string(),
Value::String(parts.auth_context.api_key_id.clone()),
);
object.insert(
"api_key_is_standalone".to_string(),
Value::Bool(parts.auth_context.api_key_is_standalone),
);
object.insert(
"username".to_string(),
parts
.auth_context
.username
.clone()
.map(Value::String)
.unwrap_or(Value::Null),
);
object.insert(
"api_key_name".to_string(),
parts
.auth_context
.api_key_name
.clone()
.map(Value::String)
.unwrap_or(Value::Null),
);
object.insert(
"request_id".to_string(),
Value::String(parts.request_id.to_string()),
);
object.insert(
"candidate_id".to_string(),
Value::String(parts.candidate_id.to_string()),
);
object.insert(
"candidate_index".to_string(),
Value::Number(parts.candidate_index.into()),
);
object.insert(
"retry_index".to_string(),
Value::Number(parts.retry_index.into()),
);
object.insert("model".to_string(), Value::String(parts.model.to_string()));
object.insert(
"provider_name".to_string(),
Value::String(parts.provider_name.to_string()),
);
object.insert(
"provider_id".to_string(),
Value::String(parts.provider_id.to_string()),
);
object.insert(
"endpoint_id".to_string(),
Value::String(parts.endpoint_id.to_string()),
);
object.insert(
"key_id".to_string(),
Value::String(parts.key_id.to_string()),
);
object.insert(
"provider_api_format".to_string(),
Value::String(parts.provider_api_format.to_string()),
);
object.insert(
"client_api_format".to_string(),
Value::String(parts.client_api_format.to_string()),
);
object.insert(
"original_headers".to_string(),
serde_json::to_value(parts.original_headers).expect("control headers should serialize"),
);
object.insert(
"original_request_body".to_string(),
parts.original_request_body.unwrap_or(Value::Null),
);
if let Some(client_ip) = parts.request_origin.client_ip {
object.insert("client_ip".to_string(), Value::String(client_ip));
}
if let Some(user_agent) = parts.request_origin.user_agent {
object.insert("user_agent".to_string(), Value::String(user_agent));
}
object.insert(
"client_requested_stream".to_string(),
Value::Bool(parts.client_requested_stream),
);
object.insert(
UPSTREAM_IS_STREAM_KEY.to_string(),
Value::Bool(parts.upstream_is_stream),
);
object.insert("has_envelope".to_string(), Value::Bool(parts.has_envelope));
object.insert(
"needs_conversion".to_string(),
Value::Bool(parts.needs_conversion),
);
if let Some(key_name) = parts.key_name {
object.insert("key_name".to_string(), Value::String(key_name.to_string()));
}
if let Some(model_id) = parts.model_id {
object.insert("model_id".to_string(), Value::String(model_id.to_string()));
}
if let Some(global_model_id) = parts.global_model_id {
object.insert(
"global_model_id".to_string(),
Value::String(global_model_id.to_string()),
);
}
if let Some(global_model_name) = parts.global_model_name {
object.insert(
"global_model_name".to_string(),
Value::String(global_model_name.to_string()),
);
}
if let Some(mapped_model) = parts.mapped_model {
object.insert(
"mapped_model".to_string(),
Value::String(mapped_model.to_string()),
);
}
if let Some(candidate_group_id) = parts.candidate_group_id {
object.insert(
"candidate_group_id".to_string(),
Value::String(candidate_group_id.to_string()),
);
}
if let Some(ranking) = parts.ranking {
append_ai_ranking_metadata_to_object(&mut object, ranking);
}
if let Some(upstream_url) = parts.upstream_url {
object.insert(
"upstream_url".to_string(),
Value::String(upstream_url.to_string()),
);
}
if let Some(header_rules) = parts.header_rules {
object.insert("header_rules".to_string(), header_rules.clone());
}
if let Some(body_rules) = parts.body_rules {
object.insert("body_rules".to_string(), body_rules.clone());
}
if let Some(provider_request_method) = parts.provider_request_method {
object.insert(
"provider_request_method".to_string(),
provider_request_method,
);
}
if let Some(provider_request_headers) = parts.provider_request_headers {
object.insert(
"provider_request_headers".to_string(),
serde_json::to_value(provider_request_headers)
.expect("provider request headers should serialize"),
);
}
if let Some(pool_key_index) = parts.pool_key_index {
object.insert(
"pool_key_index".to_string(),
Value::Number(pool_key_index.into()),
);
}
object.extend(parts.extra_fields);
Value::Object(object)
}
pub fn provider_stream_event_api_format_for_provider_type(
provider_type: &str,
) -> Option<&'static str> {
match provider_type.trim().to_ascii_lowercase().as_str() {
"codex" => Some("openai:responses"),
_ => None,
}
}
pub fn insert_provider_stream_event_api_format(
extra_fields: &mut Map<String, Value>,
provider_type: &str,
) {
if let Some(api_format) = provider_stream_event_api_format_for_provider_type(provider_type) {
extra_fields.insert(
"provider_stream_event_api_format".to_string(),
Value::String(api_format.to_string()),
);
}
}
pub fn build_ai_report_context_original_request_echo(
body_json: Option<&Value>,
body_bytes_b64: Option<&str>,
) -> Option<Value> {
if let Some(body_bytes_b64) = body_bytes_b64
.map(str::trim)
.filter(|value| !value.is_empty())
{
return Some(serde_json::json!({ "body_bytes_b64": body_bytes_b64 }));
}
body_json.filter(|body| !body.is_null()).cloned()
}
#[cfg(test)]
mod tests {
use super::*;
use aether_scheduler_core::{SchedulerPriorityMode, SchedulerRankingMode};
use serde_json::json;
fn sample_auth_context() -> ExecutionRuntimeAuthContext {
ExecutionRuntimeAuthContext {
user_id: "user-1".to_string(),
api_key_id: "key-1".to_string(),
username: Some("alice".to_string()),
api_key_name: Some("primary".to_string()),
balance_remaining: Some(42.0),
access_allowed: true,
api_key_is_standalone: false,
}
}
#[test]
fn report_context_builds_core_execution_fields() {
let auth_context = sample_auth_context();
let original_headers = BTreeMap::from([("x-trace-id".to_string(), "trace-a".to_string())]);
let provider_headers =
BTreeMap::from([("authorization".to_string(), "Bearer token".to_string())]);
let mut extra_fields = Map::new();
extra_fields.insert("extra".to_string(), json!("value"));
let ranking = SchedulerRankingOutcome {
original_index: 2,
ranking_index: 1,
priority_mode: SchedulerPriorityMode::Provider,
ranking_mode: SchedulerRankingMode::CacheAffinity,
priority_slot: 7,
promoted_by: Some("cached_affinity"),
demoted_by: None,
};
let report = build_ai_execution_report_context(AiExecutionReportContextParts {
auth_context: &auth_context,
request_id: "trace-a",
candidate_id: "candidate-a",
candidate_index: 3,
retry_index: 1,
pool_key_index: Some(0),
model: "gpt-5",
provider_name: "RightCode",
provider_id: "provider-1",
endpoint_id: "endpoint-1",
key_id: "key-1",
key_name: Some("primary"),
model_id: Some("model-1"),
global_model_id: Some("global-1"),
global_model_name: Some("GPT-5"),
provider_api_format: "openai:responses",
client_api_format: "openai:chat",
mapped_model: Some("gpt-5"),
candidate_group_id: Some("group-1"),
ranking: Some(&ranking),
upstream_url: Some("https://example.com/v1/responses"),
header_rules: Some(&json!({"set": []})),
body_rules: None,
provider_request_method: Some(json!("POST")),
provider_request_headers: Some(&provider_headers),
original_headers: &original_headers,
original_request_body: Some(json!({"model": "gpt-5"})),
request_origin: AiRequestOrigin {
client_ip: Some("127.0.0.1".to_string()),
user_agent: Some("test-agent".to_string()),
},
client_requested_stream: false,
upstream_is_stream: true,
has_envelope: false,
needs_conversion: true,
extra_fields,
});
assert_eq!(report["user_id"], "user-1");
assert_eq!(report["candidate_index"], 3);
assert_eq!(report["retry_index"], 1);
assert_eq!(report["pool_key_index"], 0);
assert_eq!(report["original_headers"]["x-trace-id"], "trace-a");
assert_eq!(report["original_request_body"]["model"], "gpt-5");
assert_eq!(report["ranking_index"], 1);
assert_eq!(
report["provider_request_headers"]["authorization"],
"Bearer token"
);
assert_eq!(report["extra"], "value");
}
#[test]
fn provider_stream_event_api_format_is_codex_only() {
assert_eq!(
provider_stream_event_api_format_for_provider_type("codex"),
Some("openai:responses")
);
assert_eq!(
provider_stream_event_api_format_for_provider_type(" CODEX "),
Some("openai:responses")
);
assert_eq!(
provider_stream_event_api_format_for_provider_type("openai"),
None
);
}
#[test]
fn original_request_echo_preserves_full_request_body() {
let body = json!({
"messages": [{"role": "user", "content": "large payload should be omitted"}],
"service_tier": "default",
"instructions": "Be concise.",
"thinking": {"type": "enabled", "budget_tokens": 512},
"metadata": {"trace": "keep"},
"body_bytes_b64": "aGVsbG8=",
});
let echo = build_ai_report_context_original_request_echo(Some(&body), None)
.expect("echo should be produced");
assert_eq!(echo, body);
}
#[test]
fn original_request_echo_prefers_binary_body_bytes() {
let echo = build_ai_report_context_original_request_echo(
Some(&json!({"ignored": true})),
Some("aGVsbG8="),
)
.expect("echo should be produced");
assert_eq!(echo, json!({"body_bytes_b64": "aGVsbG8="}));
}
}
@@ -0,0 +1,985 @@
use serde_json::Value;
use aether_ai_formats::api::{
is_claude_messages_shaped_body_on_openai_chat_endpoint, is_openai_responses_family_format,
normalize_api_format_alias,
};
use aether_ai_formats::{convert_request_pure_with_context, FormatContext, FormatError};
use crate::{CandidateFailureDiagnostic, CandidateFailureDiagnosticKind};
pub fn request_body_build_failure_extra_data(
body_json: &Value,
client_api_format: &str,
provider_api_format: &str,
) -> Option<Value> {
let diagnostic =
diagnose_request_body_build_failure(body_json, client_api_format, provider_api_format)?;
Some(
diagnostic
.formats(client_api_format, provider_api_format)
.source(request_body_build_source(
client_api_format,
provider_api_format,
))
.to_extra_data(),
)
}
pub fn request_conversion_failure_extra_data(
body_json: &Value,
client_api_format: &str,
provider_api_format: &str,
mapped_model: Option<&str>,
request_path: Option<&str>,
upstream_is_stream: bool,
source: impl Into<String>,
) -> Option<Value> {
let diagnostic = diagnose_request_conversion_failure(
body_json,
client_api_format,
provider_api_format,
mapped_model,
request_path,
upstream_is_stream,
)?;
Some(
diagnostic
.formats(client_api_format, provider_api_format)
.source(source)
.to_extra_data(),
)
}
pub fn same_format_provider_request_body_failure_extra_data(
body_json: &Value,
provider_api_format: &str,
body_rules: Option<&Value>,
context: &str,
) -> Option<Value> {
let diagnostic =
diagnose_same_format_provider_request_body_failure(body_json, body_rules, context)?;
Some(
diagnostic
.formats(provider_api_format, provider_api_format)
.source(context)
.to_extra_data(),
)
}
type RequestBodyBuildDiagnostic = CandidateFailureDiagnostic;
type RequestConversionDiagnostic = CandidateFailureDiagnostic;
fn diagnose_request_conversion_failure(
body_json: &Value,
client_api_format: &str,
provider_api_format: &str,
mapped_model: Option<&str>,
request_path: Option<&str>,
upstream_is_stream: bool,
) -> Option<RequestConversionDiagnostic> {
let mut context = FormatContext::default().with_upstream_stream(upstream_is_stream);
if let Some(mapped_model) = mapped_model
.map(str::trim)
.filter(|value| !value.is_empty())
{
context = context.with_mapped_model(mapped_model);
}
if let Some(request_path) = request_path
.map(str::trim)
.filter(|value| !value.is_empty())
{
context = context.with_request_path(request_path);
}
let source_format =
compatible_source_format_for_diagnostic(body_json, client_api_format, provider_api_format);
match convert_request_pure_with_context(
source_format.as_str(),
provider_api_format,
body_json,
&context,
) {
Ok(_) => {
diagnose_request_body_build_failure(body_json, client_api_format, provider_api_format)
.filter(CandidateFailureDiagnostic::has_specific_path)
.or_else(|| Some(fallback_request_conversion_diagnostic()))
}
Err(error) => {
let format_diagnostic =
diagnostic_from_format_error(&error, client_api_format, provider_api_format);
if format_diagnostic.has_specific_path() {
Some(format_diagnostic)
} else {
diagnose_request_body_build_failure(
body_json,
client_api_format,
provider_api_format,
)
.filter(CandidateFailureDiagnostic::has_specific_path)
.or(Some(format_diagnostic))
}
}
}
}
fn fallback_request_conversion_diagnostic() -> RequestConversionDiagnostic {
diagnostic(
"$",
"请求体转换本身已通过;失败可能发生在 Body 规则应用或后续上游请求体语义校验",
)
}
fn diagnose_request_body_build_failure(
body_json: &Value,
client_api_format: &str,
provider_api_format: &str,
) -> Option<RequestBodyBuildDiagnostic> {
if !body_json.is_object() {
return Some(diagnostic("$", "请求体必须是 JSON object"));
}
if is_openai_responses_client_format(client_api_format) {
if let Some(diagnostic) = diagnose_openai_responses_request(body_json) {
return Some(diagnostic);
}
return Some(diagnostic(
"$",
"OpenAI Responses 请求体初步结构检查通过;失败可能发生在后续跨格式转换或 Body 规则应用",
));
}
if client_api_format == "openai:chat"
&& (provider_api_format.starts_with("claude:")
|| provider_api_format.starts_with("gemini:"))
{
return diagnose_openai_chat_cross_format_request(body_json, provider_api_format);
}
Some(diagnostic(
"$",
"请求体转换失败;当前转换器未返回更细的字段路径",
))
}
fn compatible_source_format_for_diagnostic(
body_json: &Value,
client_api_format: &str,
provider_api_format: &str,
) -> String {
let client_api_format = normalize_api_format_alias(client_api_format);
let provider_api_format = normalize_api_format_alias(provider_api_format);
if client_api_format == "openai:chat"
&& provider_api_format == "claude:messages"
&& is_claude_messages_shaped_body_on_openai_chat_endpoint(body_json)
{
return "claude:messages".to_string();
}
client_api_format
}
fn diagnostic_from_format_error(
error: &FormatError,
client_api_format: &str,
provider_api_format: &str,
) -> RequestConversionDiagnostic {
CandidateFailureDiagnostic::new(
CandidateFailureDiagnosticKind::RequestConversion,
format_error_path(error),
format_error_message(error, client_api_format, provider_api_format),
)
}
fn format_error_path(error: &FormatError) -> String {
match error {
FormatError::UnsupportedField { field, .. }
| FormatError::UnauditedField { field, .. }
| FormatError::InvalidEnumValue { field, .. }
| FormatError::LossyConversionBlocked { field, .. }
| FormatError::InvalidTargetField { field, .. } => field_to_json_path(field),
FormatError::UnsupportedFormat(_)
| FormatError::RequestParseFailed { .. }
| FormatError::RequestEmitFailed { .. }
| FormatError::ResponseParseFailed { .. }
| FormatError::ResponseEmitFailed { .. } => "$".to_string(),
}
}
fn field_to_json_path(field: &str) -> String {
let field = field.trim();
if field.is_empty() || field == "$" {
return "$".to_string();
}
if field.starts_with('$') {
return field.to_string();
}
format!("$.{}", field)
.replace("[].", "[*].")
.replace("[]", "[*]")
}
fn format_error_message(
error: &FormatError,
client_api_format: &str,
provider_api_format: &str,
) -> String {
match error {
FormatError::UnsupportedFormat(format) => {
format!("不支持的 API 格式 {format},无法执行 {client_api_format} → {provider_api_format} 转换")
}
FormatError::RequestParseFailed { format } => {
format!("无法按 {format} 解析请求体;请检查请求体结构和字段类型是否符合该格式")
}
FormatError::RequestEmitFailed { format } => {
format!("无法生成 {format} 上游请求体;请检查源请求是否缺少目标格式必需字段或包含不可映射结构")
}
FormatError::ResponseParseFailed { format } => {
format!("无法按 {format} 解析响应体")
}
FormatError::ResponseEmitFailed { format } => {
format!("无法生成 {format} 响应体")
}
FormatError::UnsupportedField {
format,
field,
reason,
} => {
format!("{format} 字段 {field} 不支持跨格式转换:{reason}")
}
FormatError::UnauditedField {
source_format,
target_format,
field,
reason,
} => {
format!("{source_format} 字段 {field} 尚未审计,不能转换到 {target_format}:{reason}")
}
FormatError::InvalidEnumValue {
format,
field,
value,
} => {
format!("{format} 字段 {field} 的枚举值 {value:?} 无效,无法转换")
}
FormatError::LossyConversionBlocked {
source_format,
target_format,
field,
reason,
} => {
format!("{source_format} 字段 {field} 不能无损转换到 {target_format}:{reason}")
}
FormatError::InvalidTargetField {
format,
field,
reason,
} => {
format!("目标格式 {format} 字段 {field} 无效:{reason}")
}
}
}
fn is_openai_responses_client_format(client_api_format: &str) -> bool {
is_openai_responses_family_format(client_api_format)
}
fn diagnose_same_format_provider_request_body_failure(
body_json: &Value,
body_rules: Option<&Value>,
context: &str,
) -> Option<RequestBodyBuildDiagnostic> {
if !body_json.is_object() {
return Some(diagnostic("$", "反代请求体必须是 JSON object"));
}
if body_rules.is_some_and(|rules| !rules.is_array()) {
return Some(diagnostic(
"$.endpoint.body_rules",
"Endpoint Body 规则必须是数组,本地反代无法应用该配置",
));
}
match context {
"kiro_envelope" => Some(diagnostic(
"$",
"Kiro 反代请求体包装失败;请检查 Kiro auth_config 与 Endpoint Body 规则",
)),
"antigravity_envelope" => Some(diagnostic(
"$",
"Antigravity 反代请求体包装失败;请检查请求体是否满足该传输封装要求",
)),
_ => Some(diagnostic(
"$",
"反代请求体构建失败;当前路径未返回更细的字段信息",
)),
}
}
fn diagnose_openai_chat_cross_format_request(
body_json: &Value,
provider_api_format: &str,
) -> Option<RequestBodyBuildDiagnostic> {
if provider_api_format.starts_with("claude:")
&& is_claude_messages_shaped_body_on_openai_chat_endpoint(body_json)
{
return Some(diagnostic(
"$",
"请求体看起来是 Claude Messages 原生格式;Aether 会按 Claude Messages 兼容路径处理,若仍失败请检查 Claude messages/tools/tool_choice 结构或 Body 规则",
));
}
let request = body_json.as_object()?;
if let Some(messages) = request.get("messages") {
let Some(messages) = messages.as_array() else {
return Some(diagnostic(
"$.messages",
"OpenAI Chat 的 messages 必须是数组",
));
};
for (message_index, message) in messages.iter().enumerate() {
let Some(message_object) = message.as_object() else {
return Some(diagnostic(
format!("$.messages[{message_index}]"),
"message 必须是 object",
));
};
let role = message_object
.get("role")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
match role.as_str() {
"system" | "developer" => {
if let Some(diagnostic) = diagnose_openai_text_content(
message_object.get("content"),
format!("$.messages[{message_index}].content"),
) {
return Some(diagnostic);
}
}
"user" | "assistant" => {
if let Some(diagnostic) = diagnose_openai_content_blocks(
message_object.get("content"),
format!("$.messages[{message_index}].content"),
role.as_str(),
) {
return Some(diagnostic);
}
if role == "assistant" {
if let Some(diagnostic) = diagnose_openai_assistant_tool_calls(
message_object.get("tool_calls"),
format!("$.messages[{message_index}].tool_calls"),
) {
return Some(diagnostic);
}
}
}
"tool" => {
let valid_tool_call_id = message_object
.get("tool_call_id")
.and_then(Value::as_str)
.map(str::trim)
.is_some_and(|value| !value.is_empty());
if !valid_tool_call_id {
return Some(diagnostic(
format!("$.messages[{message_index}].tool_call_id"),
"tool 消息必须包含非空 tool_call_id",
));
}
}
_ => {}
}
}
}
if let Some(diagnostic) = diagnose_openai_tools(request.get("tools"), provider_api_format) {
return Some(diagnostic);
}
diagnose_openai_tool_choice(request.get("tool_choice"))
}
fn diagnose_openai_responses_request(body_json: &Value) -> Option<RequestBodyBuildDiagnostic> {
let request = body_json.as_object()?;
if let Some(diagnostic) = diagnose_openai_responses_text_content(
request.get("instructions"),
"$.instructions".to_string(),
) {
return Some(diagnostic);
}
if let Some(diagnostic) = diagnose_openai_responses_input(request.get("input")) {
return Some(diagnostic);
}
if let Some(diagnostic) = diagnose_openai_responses_tools(request.get("tools")) {
return Some(diagnostic);
}
diagnose_openai_responses_tool_choice(request.get("tool_choice"))
}
fn diagnose_openai_responses_input(input: Option<&Value>) -> Option<RequestBodyBuildDiagnostic> {
let input = input?;
match input {
Value::Null | Value::String(_) => None,
Value::Array(items) => {
for (item_index, item) in items.iter().enumerate() {
if item.is_string() {
continue;
}
let item_path = format!("$.input[{item_index}]");
let Some(item_object) = item.as_object() else {
return Some(diagnostic(
item_path,
"OpenAI Responses input 数组项必须是 string 或 object",
));
};
let item_type = item_object
.get("type")
.and_then(Value::as_str)
.unwrap_or("message")
.trim()
.to_ascii_lowercase();
match item_type.as_str() {
"message" => {
let role = item_object
.get("role")
.and_then(Value::as_str)
.unwrap_or("user")
.trim()
.to_ascii_lowercase();
if role == "system" || role == "developer" {
if let Some(diagnostic) = diagnose_openai_responses_text_content(
item_object.get("content"),
format!("{item_path}.content"),
) {
return Some(diagnostic);
}
} else if let Some(diagnostic) = diagnose_openai_responses_message_content(
item_object.get("content"),
format!("{item_path}.content"),
) {
return Some(diagnostic);
}
}
"function_call" => {
let valid_name = item_object
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.is_some_and(|value| !value.is_empty());
if !valid_name {
return Some(diagnostic(
format!("{item_path}.name"),
"function_call 必须包含非空 name",
));
}
}
_ => {}
}
}
None
}
_ => Some(diagnostic(
"$.input",
"OpenAI Responses input 必须是 string、array 或 null",
)),
}
}
fn diagnose_openai_responses_text_content(
content: Option<&Value>,
path: String,
) -> Option<RequestBodyBuildDiagnostic> {
match content {
None | Some(Value::Null) | Some(Value::String(_)) => None,
Some(Value::Array(parts)) => {
for (part_index, part) in parts.iter().enumerate() {
if !part.is_object() {
return Some(diagnostic(
format!("{path}[{part_index}]"),
"文本 content 数组项必须是 object",
));
}
}
None
}
Some(_) => Some(diagnostic(
path,
"文本 content 必须是 string、array 或 null",
)),
}
}
fn diagnose_openai_responses_message_content(
content: Option<&Value>,
path: String,
) -> Option<RequestBodyBuildDiagnostic> {
match content {
None | Some(Value::Null) | Some(Value::String(_)) => None,
Some(Value::Array(parts)) => {
for (part_index, part) in parts.iter().enumerate() {
let part_path = format!("{path}[{part_index}]");
let Some(part_object) = part.as_object() else {
return Some(diagnostic(part_path, "message content 数组项必须是 object"));
};
let part_type = part_object
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
if matches!(
part_type.as_str(),
"input_image" | "output_image" | "image_url"
) && image_part_url(part_object).is_none()
{
return Some(diagnostic(
part_path,
"图片 content 缺少 image_url/url,无法规范化为 OpenAI Chat 图片内容",
));
}
}
None
}
Some(_) => None,
}
}
fn diagnose_openai_text_content(
content: Option<&Value>,
path: String,
) -> Option<RequestBodyBuildDiagnostic> {
match content {
None | Some(Value::Null) | Some(Value::String(_)) => None,
Some(Value::Array(parts)) => {
for (part_index, part) in parts.iter().enumerate() {
if !part.is_object() {
return Some(diagnostic(
format!("{path}[{part_index}]"),
"content 数组项必须是 object",
));
}
}
None
}
Some(_) => Some(diagnostic(path, "content 必须是 string、array 或 null")),
}
}
fn diagnose_openai_content_blocks(
content: Option<&Value>,
path: String,
role: &str,
) -> Option<RequestBodyBuildDiagnostic> {
match content {
None | Some(Value::Null) | Some(Value::String(_)) => None,
Some(Value::Array(parts)) => {
for (part_index, part) in parts.iter().enumerate() {
let part_path = format!("{path}[{part_index}]");
let Some(part_object) = part.as_object() else {
return Some(diagnostic(part_path, "content 数组项必须是 object"));
};
let part_type = part_object
.get("type")
.and_then(Value::as_str)
.unwrap_or_default();
if matches!(part_type, "image_url" | "input_image" | "output_image")
&& role == "user"
&& image_part_url(part_object).is_none()
{
return Some(diagnostic(
part_path,
"图片 content 缺少 image_url/url,无法转换为 Claude image block",
));
}
}
None
}
Some(_) => Some(diagnostic(path, "content 必须是 string、array 或 null")),
}
}
fn diagnose_openai_assistant_tool_calls(
tool_calls: Option<&Value>,
path: String,
) -> Option<RequestBodyBuildDiagnostic> {
let tool_calls = tool_calls?;
let Some(tool_calls) = tool_calls.as_array() else {
return Some(diagnostic(path, "assistant.tool_calls 必须是数组"));
};
for (tool_call_index, tool_call) in tool_calls.iter().enumerate() {
let tool_call_path = format!("{path}[{tool_call_index}]");
let Some(tool_call_object) = tool_call.as_object() else {
return Some(diagnostic(tool_call_path, "tool_call 必须是 object"));
};
let Some(function) = tool_call_object.get("function").and_then(Value::as_object) else {
return Some(diagnostic(
format!("{tool_call_path}.function"),
"tool_call 必须包含 function object",
));
};
let valid_name = function
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.is_some_and(|value| !value.is_empty());
if !valid_name {
return Some(diagnostic(
format!("{tool_call_path}.function.name"),
"tool_call.function.name 必须是非空字符串",
));
}
}
None
}
fn diagnose_openai_tools(
tools: Option<&Value>,
provider_api_format: &str,
) -> Option<RequestBodyBuildDiagnostic> {
let tools = tools?;
let Some(tools) = tools.as_array() else {
return Some(diagnostic("$.tools", "OpenAI Chat 的 tools 必须是数组"));
};
for (tool_index, tool) in tools.iter().enumerate() {
let tool_path = format!("$.tools[{tool_index}]");
let Some(tool_object) = tool.as_object() else {
return Some(diagnostic(tool_path, "tool 必须是 object"));
};
if tool_object
.get("type")
.and_then(Value::as_str)
.is_some_and(|value| value != "function")
{
continue;
}
let Some(function) = tool_object.get("function").and_then(Value::as_object) else {
let native_tool_hint = if provider_api_format.starts_with("claude:") {
";如果这是 Claude 原生 tool,请改为 OpenAI function tool 格式"
} else if provider_api_format.starts_with("gemini:") {
";如果这是 Gemini 原生 tool,请改为 OpenAI function tool 格式"
} else {
""
};
return Some(diagnostic(
format!("{tool_path}.function"),
format!("OpenAI tool 必须包含 function object{native_tool_hint}"),
));
};
let valid_name = function
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.is_some_and(|value| !value.is_empty());
if !valid_name {
return Some(diagnostic(
format!("{tool_path}.function.name"),
"OpenAI tool 的 function.name 必须是非空字符串",
));
}
}
None
}
fn diagnose_openai_responses_tools(tools: Option<&Value>) -> Option<RequestBodyBuildDiagnostic> {
let tools = tools?;
let tool_values = tools.as_array()?;
for (tool_index, tool) in tool_values.iter().enumerate() {
let tool_path = format!("$.tools[{tool_index}]");
let Some(tool_object) = tool.as_object() else {
return Some(diagnostic(tool_path, "OpenAI Responses tool 必须是 object"));
};
let tool_type = tool_object
.get("type")
.and_then(Value::as_str)
.unwrap_or("function")
.trim()
.to_ascii_lowercase();
if tool_type.starts_with("web_search")
|| tool_object.get("function").is_some()
|| tool_type != "function"
{
continue;
}
let valid_name = tool_object
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.is_some_and(|value| !value.is_empty());
if !valid_name {
return Some(diagnostic(
format!("{tool_path}.name"),
"OpenAI Responses function tool 必须包含非空 name",
));
}
}
None
}
fn diagnose_openai_responses_tool_choice(
tool_choice: Option<&Value>,
) -> Option<RequestBodyBuildDiagnostic> {
let Some(Value::Object(object)) = tool_choice else {
return None;
};
let is_cli_function_choice = object.get("function").is_none()
&& object
.get("type")
.and_then(Value::as_str)
.is_some_and(|value| value.eq_ignore_ascii_case("function"));
if !is_cli_function_choice {
return None;
}
let valid_name = object
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.is_some_and(|value| !value.is_empty());
if valid_name {
None
} else {
Some(diagnostic(
"$.tool_choice.name",
"OpenAI Responses tool_choice 指定 function 时必须包含非空 name",
))
}
}
fn diagnose_openai_tool_choice(tool_choice: Option<&Value>) -> Option<RequestBodyBuildDiagnostic> {
let Some(Value::Object(object)) = tool_choice else {
return None;
};
let valid_name = object
.get("function")
.and_then(Value::as_object)
.and_then(|function| function.get("name"))
.and_then(Value::as_str)
.map(str::trim)
.is_some_and(|value| !value.is_empty());
if valid_name {
None
} else {
Some(diagnostic(
"$.tool_choice.function.name",
"tool_choice 指定具体工具时必须包含非空 function.name",
))
}
}
fn image_part_url(part_object: &serde_json::Map<String, Value>) -> Option<&str> {
part_object
.get("image_url")
.and_then(|value| {
value.as_str().or_else(|| {
value
.as_object()
.and_then(|object| object.get("url"))
.and_then(Value::as_str)
})
})
.or_else(|| part_object.get("url").and_then(Value::as_str))
.map(str::trim)
.filter(|value| !value.is_empty())
}
fn diagnostic(path: impl Into<String>, message: impl Into<String>) -> RequestBodyBuildDiagnostic {
CandidateFailureDiagnostic::new(
CandidateFailureDiagnosticKind::RequestBodyBuild,
path,
message,
)
}
fn request_body_build_source(client_api_format: &str, provider_api_format: &str) -> String {
format!("{client_api_format}_to_{provider_api_format}")
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{request_body_build_failure_extra_data, request_conversion_failure_extra_data};
#[test]
fn openai_chat_to_claude_recognizes_compatible_claude_native_tool_shape() {
let body = json!({
"model": "gpt-5.4",
"messages": [{ "role": "user", "content": "hello" }],
"tools": [{
"name": "read_file",
"description": "Read a file",
"input_schema": { "type": "object" }
}]
});
let diagnostic =
request_body_build_failure_extra_data(&body, "openai:chat", "claude:messages")
.expect("diagnostic");
assert_eq!(diagnostic["request_body_build_error"]["path"], "$");
assert_eq!(
diagnostic["failure_diagnostic"]["kind"],
"request_body_build"
);
assert_eq!(
diagnostic["failure_diagnostic"]["source"],
"openai:chat_to_claude:messages"
);
assert!(diagnostic["request_body_build_error"]["message"]
.as_str()
.expect("message")
.contains("Claude Messages 原生格式"));
}
#[test]
fn openai_chat_to_claude_reports_invalid_message_content_part() {
let body = json!({
"model": "gpt-5.4",
"messages": [{
"role": "user",
"content": ["not-an-object"]
}]
});
let diagnostic =
request_body_build_failure_extra_data(&body, "openai:chat", "claude:messages")
.expect("diagnostic");
assert_eq!(
diagnostic["request_body_build_error"]["path"],
"$.messages[0].content[0]"
);
}
#[test]
fn openai_chat_to_gemini_reports_gemini_native_tool_shape() {
let body = json!({
"model": "gpt-5.4",
"messages": [{ "role": "user", "content": "hello" }],
"tools": [{
"functionDeclarations": [{
"name": "search",
"parameters": { "type": "object" }
}]
}]
});
let diagnostic =
request_body_build_failure_extra_data(&body, "openai:chat", "gemini:generate_content")
.expect("diagnostic");
assert_eq!(
diagnostic["request_body_build_error"]["path"],
"$.tools[0].function"
);
assert!(diagnostic["request_body_build_error"]["message"]
.as_str()
.expect("message")
.contains("Gemini 原生 tool"));
}
#[test]
fn openai_responses_reports_invalid_function_call_name() {
let body = json!({
"model": "gpt-5.4",
"input": [{
"type": "function_call",
"arguments": "{}"
}]
});
let diagnostic =
request_body_build_failure_extra_data(&body, "openai:responses", "claude:messages")
.expect("diagnostic");
assert_eq!(
diagnostic["request_body_build_error"]["path"],
"$.input[0].name"
);
}
#[test]
fn openai_responses_reports_invalid_tool_choice_name() {
let body = json!({
"model": "gpt-5.4",
"input": "hello",
"tool_choice": { "type": "function" }
});
let diagnostic = request_body_build_failure_extra_data(
&body,
"openai:responses",
"gemini:generate_content",
)
.expect("diagnostic");
assert_eq!(
diagnostic["request_body_build_error"]["path"],
"$.tool_choice.name"
);
}
#[test]
fn request_conversion_reports_lossy_incompatible_field_path() {
let body = json!({
"model": "gpt-5.4",
"messages": [{ "role": "user", "content": "hello" }],
"n": 2
});
let diagnostic = request_conversion_failure_extra_data(
&body,
"openai:chat",
"openai:responses",
Some("gpt-5.4"),
Some("/v1/chat/completions"),
false,
"test_conversion",
)
.expect("diagnostic");
assert_eq!(
diagnostic["failure_diagnostic"]["kind"],
"request_conversion"
);
assert_eq!(diagnostic["failure_diagnostic"]["path"], "$.n");
assert_eq!(diagnostic["request_conversion_error"]["path"], "$.n");
assert!(diagnostic["failure_diagnostic"]["message"]
.as_str()
.expect("message")
.contains("字段 n"));
}
#[test]
fn same_format_provider_reports_non_object_body() {
let diagnostic = super::same_format_provider_request_body_failure_extra_data(
&json!("raw"),
"openai:chat",
None,
"same_format",
)
.expect("diagnostic");
assert_eq!(diagnostic["request_body_build_error"]["path"], "$");
assert!(diagnostic["request_body_build_error"]["message"]
.as_str()
.expect("message")
.contains("反代请求体必须是 JSON object"));
}
#[test]
fn same_format_provider_reports_invalid_body_rules_shape() {
let diagnostic = super::same_format_provider_request_body_failure_extra_data(
&json!({ "model": "gpt-5.4" }),
"openai:chat",
Some(&json!({ "action": "set" })),
"same_format",
)
.expect("diagnostic");
assert_eq!(
diagnostic["request_body_build_error"]["path"],
"$.endpoint.body_rules"
);
}
}
@@ -0,0 +1,483 @@
pub trait AiRuntimeMissDiagnosticPort: Send + Sync {
type Decision: Send + Sync;
type Diagnostic: Send;
fn build_runtime_miss_diagnostic(
&self,
decision: &Self::Decision,
plan_kind: &str,
requested_model: Option<&str>,
reason: &str,
) -> Self::Diagnostic;
fn set_candidate_count(&self, diagnostic: &mut Self::Diagnostic, candidate_count: usize);
fn apply_candidate_evaluation_progress(
&self,
diagnostic: &mut Self::Diagnostic,
candidate_count: usize,
);
fn apply_candidate_terminal_plan_reason(
&self,
diagnostic: &mut Self::Diagnostic,
no_plan_reason: &'static str,
);
fn record_candidate_skip_reason(
&self,
diagnostic: &mut Self::Diagnostic,
skip_reason: &'static str,
);
fn set_runtime_miss_diagnostic(&self, trace_id: &str, diagnostic: Self::Diagnostic);
fn mutate_runtime_miss_diagnostic<F>(&self, trace_id: &str, apply: F)
where
F: FnOnce(&mut Self::Diagnostic) + Send;
fn runtime_miss_diagnostic_has_candidate_signal(&self, trace_id: &str) -> bool;
}
pub const AUTH_API_KEY_CONCURRENCY_LIMIT_SKIP_REASON: &str =
"auth_api_key_concurrency_limit_reached";
pub const LEGACY_API_KEY_CONCURRENCY_LIMIT_SKIP_REASON: &str = "api_key_concurrency_limit_reached";
fn auth_api_key_concurrency_skip_count<Diagnostic>(diagnostic: &Diagnostic) -> usize
where
Diagnostic: AiRuntimeMissDiagnosticFields,
{
diagnostic.skip_reason_count(AUTH_API_KEY_CONCURRENCY_LIMIT_SKIP_REASON)
+ diagnostic.skip_reason_count(LEGACY_API_KEY_CONCURRENCY_LIMIT_SKIP_REASON)
}
pub trait AiRuntimeMissDiagnosticFields {
fn set_reason(&mut self, reason: String);
fn set_candidate_count(&mut self, candidate_count: usize);
fn candidate_count(&self) -> Option<usize>;
fn skipped_candidate_count(&self) -> Option<usize>;
fn skip_reason_count(&self, skip_reason: &str) -> usize;
fn skip_reason_len(&self) -> usize;
fn record_skip_reason(&mut self, skip_reason: &'static str);
}
pub fn apply_ai_runtime_candidate_evaluation_progress_to_diagnostic<Diagnostic>(
diagnostic: &mut Diagnostic,
candidate_count: usize,
) where
Diagnostic: AiRuntimeMissDiagnosticFields,
{
diagnostic.set_candidate_count(candidate_count);
diagnostic.set_reason(if candidate_count == 0 {
"candidate_list_empty".to_string()
} else {
"candidate_evaluation_incomplete".to_string()
});
}
pub fn apply_ai_runtime_candidate_terminal_plan_reason_to_diagnostic<Diagnostic>(
diagnostic: &mut Diagnostic,
no_plan_reason: &'static str,
) where
Diagnostic: AiRuntimeMissDiagnosticFields,
{
let candidate_count = diagnostic.candidate_count().unwrap_or(0);
let skipped_candidate_count = diagnostic.skipped_candidate_count().unwrap_or(0);
diagnostic.set_reason(if candidate_count == 0 {
"candidate_list_empty".to_string()
} else if skipped_candidate_count >= candidate_count
&& diagnostic.skip_reason_len() == 1
&& auth_api_key_concurrency_skip_count(diagnostic) > 0
{
AUTH_API_KEY_CONCURRENCY_LIMIT_SKIP_REASON.to_string()
} else if skipped_candidate_count >= candidate_count {
"all_candidates_skipped".to_string()
} else {
no_plan_reason.to_string()
});
}
pub fn record_ai_runtime_candidate_skip_reason_on_diagnostic<Diagnostic>(
diagnostic: &mut Diagnostic,
skip_reason: &'static str,
) where
Diagnostic: AiRuntimeMissDiagnosticFields,
{
diagnostic.record_skip_reason(skip_reason);
}
pub fn set_ai_runtime_miss_diagnostic_reason<Port>(
port: &Port,
trace_id: &str,
decision: &Port::Decision,
plan_kind: &str,
requested_model: Option<&str>,
reason: &str,
) where
Port: AiRuntimeMissDiagnosticPort,
{
port.set_runtime_miss_diagnostic(
trace_id,
port.build_runtime_miss_diagnostic(decision, plan_kind, requested_model, reason),
);
}
pub fn build_ai_runtime_execution_exhausted_diagnostic<Port>(
port: &Port,
decision: &Port::Decision,
plan_kind: &str,
requested_model: Option<&str>,
candidate_count: usize,
) -> Port::Diagnostic
where
Port: AiRuntimeMissDiagnosticPort,
{
let mut diagnostic = port.build_runtime_miss_diagnostic(
decision,
plan_kind,
requested_model,
"execution_runtime_candidates_exhausted",
);
port.set_candidate_count(&mut diagnostic, candidate_count);
diagnostic
}
pub fn set_ai_runtime_execution_exhausted_diagnostic<Port>(
port: &Port,
trace_id: &str,
decision: &Port::Decision,
plan_kind: &str,
requested_model: Option<&str>,
candidate_count: usize,
) where
Port: AiRuntimeMissDiagnosticPort,
{
port.set_runtime_miss_diagnostic(
trace_id,
build_ai_runtime_execution_exhausted_diagnostic(
port,
decision,
plan_kind,
requested_model,
candidate_count,
),
);
}
pub fn build_ai_runtime_candidate_evaluation_diagnostic<Port>(
port: &Port,
decision: &Port::Decision,
plan_kind: &str,
requested_model: Option<&str>,
candidate_count: usize,
) -> Port::Diagnostic
where
Port: AiRuntimeMissDiagnosticPort,
{
let mut diagnostic = port.build_runtime_miss_diagnostic(
decision,
plan_kind,
requested_model,
"candidate_evaluation_incomplete",
);
port.apply_candidate_evaluation_progress(&mut diagnostic, candidate_count);
diagnostic
}
pub fn set_ai_runtime_candidate_evaluation_diagnostic<Port>(
port: &Port,
trace_id: &str,
decision: &Port::Decision,
plan_kind: &str,
requested_model: Option<&str>,
candidate_count: usize,
) where
Port: AiRuntimeMissDiagnosticPort,
{
port.set_runtime_miss_diagnostic(
trace_id,
build_ai_runtime_candidate_evaluation_diagnostic(
port,
decision,
plan_kind,
requested_model,
candidate_count,
),
);
}
pub fn apply_ai_runtime_candidate_evaluation_progress<Port>(
port: &Port,
trace_id: &str,
candidate_count: usize,
) where
Port: AiRuntimeMissDiagnosticPort,
{
port.mutate_runtime_miss_diagnostic(trace_id, |diagnostic| {
port.apply_candidate_evaluation_progress(diagnostic, candidate_count);
});
}
pub fn apply_ai_runtime_candidate_evaluation_progress_preserving_candidate_signal<Port>(
port: &Port,
trace_id: &str,
candidate_count: usize,
) where
Port: AiRuntimeMissDiagnosticPort,
{
let preserve_existing_candidate_signal =
candidate_count == 0 && port.runtime_miss_diagnostic_has_candidate_signal(trace_id);
if preserve_existing_candidate_signal {
return;
}
apply_ai_runtime_candidate_evaluation_progress(port, trace_id, candidate_count);
}
pub fn apply_ai_runtime_candidate_terminal_reason<Port>(
port: &Port,
trace_id: &str,
no_plan_reason: &'static str,
) where
Port: AiRuntimeMissDiagnosticPort,
{
port.mutate_runtime_miss_diagnostic(trace_id, |diagnostic| {
port.apply_candidate_terminal_plan_reason(diagnostic, no_plan_reason);
});
}
pub fn record_ai_runtime_candidate_skip_reason<Port>(
port: &Port,
trace_id: &str,
skip_reason: &'static str,
) where
Port: AiRuntimeMissDiagnosticPort,
{
port.mutate_runtime_miss_diagnostic(trace_id, |diagnostic| {
port.record_candidate_skip_reason(diagnostic, skip_reason);
});
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
use std::sync::Mutex;
#[derive(Debug, Clone, PartialEq, Eq)]
struct TestDecision {
id: &'static str,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct TestDiagnostic {
decision_id: String,
plan_kind: String,
requested_model: Option<String>,
reason: String,
candidate_count: Option<usize>,
terminal_reason: Option<&'static str>,
skip_reasons: BTreeMap<&'static str, usize>,
}
#[derive(Default)]
struct TestPort {
diagnostics: Mutex<BTreeMap<String, TestDiagnostic>>,
}
impl AiRuntimeMissDiagnosticPort for TestPort {
type Decision = TestDecision;
type Diagnostic = TestDiagnostic;
fn build_runtime_miss_diagnostic(
&self,
decision: &Self::Decision,
plan_kind: &str,
requested_model: Option<&str>,
reason: &str,
) -> Self::Diagnostic {
TestDiagnostic {
decision_id: decision.id.to_string(),
plan_kind: plan_kind.to_string(),
requested_model: requested_model.map(str::to_string),
reason: reason.to_string(),
..Default::default()
}
}
fn set_candidate_count(&self, diagnostic: &mut Self::Diagnostic, candidate_count: usize) {
diagnostic.candidate_count = Some(candidate_count);
}
fn apply_candidate_evaluation_progress(
&self,
diagnostic: &mut Self::Diagnostic,
candidate_count: usize,
) {
diagnostic.candidate_count = Some(candidate_count);
}
fn apply_candidate_terminal_plan_reason(
&self,
diagnostic: &mut Self::Diagnostic,
no_plan_reason: &'static str,
) {
diagnostic.terminal_reason = Some(no_plan_reason);
}
fn record_candidate_skip_reason(
&self,
diagnostic: &mut Self::Diagnostic,
skip_reason: &'static str,
) {
*diagnostic.skip_reasons.entry(skip_reason).or_insert(0) += 1;
}
fn set_runtime_miss_diagnostic(&self, trace_id: &str, diagnostic: Self::Diagnostic) {
self.diagnostics
.lock()
.unwrap()
.insert(trace_id.to_string(), diagnostic);
}
fn mutate_runtime_miss_diagnostic<F>(&self, trace_id: &str, apply: F)
where
F: FnOnce(&mut Self::Diagnostic) + Send,
{
let mut diagnostics = self.diagnostics.lock().unwrap();
let diagnostic = diagnostics.entry(trace_id.to_string()).or_default();
apply(diagnostic);
}
fn runtime_miss_diagnostic_has_candidate_signal(&self, trace_id: &str) -> bool {
self.diagnostics
.lock()
.unwrap()
.get(trace_id)
.is_some_and(|diagnostic| diagnostic.candidate_count.unwrap_or_default() > 0)
}
}
impl AiRuntimeMissDiagnosticFields for TestDiagnostic {
fn set_reason(&mut self, reason: String) {
self.reason = reason;
}
fn set_candidate_count(&mut self, candidate_count: usize) {
self.candidate_count = Some(candidate_count);
}
fn candidate_count(&self) -> Option<usize> {
self.candidate_count
}
fn skipped_candidate_count(&self) -> Option<usize> {
self.skip_reasons.values().copied().sum::<usize>().into()
}
fn skip_reason_count(&self, skip_reason: &str) -> usize {
self.skip_reasons.get(skip_reason).copied().unwrap_or(0)
}
fn skip_reason_len(&self) -> usize {
self.skip_reasons.len()
}
fn record_skip_reason(&mut self, skip_reason: &'static str) {
*self.skip_reasons.entry(skip_reason).or_insert(0) += 1;
}
}
#[test]
fn runtime_miss_builds_and_sets_execution_exhausted_diagnostic() {
let port = TestPort::default();
set_ai_runtime_execution_exhausted_diagnostic(
&port,
"trace-a",
&TestDecision { id: "decision-a" },
"openai_chat",
Some("gpt-5"),
3,
);
let diagnostic = port.diagnostics.lock().unwrap().get("trace-a").cloned();
assert_eq!(
diagnostic,
Some(TestDiagnostic {
decision_id: "decision-a".to_string(),
plan_kind: "openai_chat".to_string(),
requested_model: Some("gpt-5".to_string()),
reason: "execution_runtime_candidates_exhausted".to_string(),
candidate_count: Some(3),
..Default::default()
})
);
}
#[test]
fn runtime_miss_preserves_candidate_signal_and_records_terminal_updates() {
let port = TestPort::default();
apply_ai_runtime_candidate_evaluation_progress(&port, "trace-a", 2);
apply_ai_runtime_candidate_evaluation_progress_preserving_candidate_signal(
&port, "trace-a", 0,
);
apply_ai_runtime_candidate_terminal_reason(&port, "trace-a", "no_local_sync_plans");
record_ai_runtime_candidate_skip_reason(&port, "trace-a", "transport_missing");
let diagnostic = port.diagnostics.lock().unwrap().get("trace-a").cloned();
assert_eq!(
diagnostic,
Some(TestDiagnostic {
candidate_count: Some(2),
terminal_reason: Some("no_local_sync_plans"),
skip_reasons: BTreeMap::from([("transport_missing", 1)]),
..Default::default()
})
);
}
#[test]
fn runtime_miss_diagnostic_field_helpers_apply_candidate_reason_state_machine() {
let mut diagnostic = TestDiagnostic::default();
apply_ai_runtime_candidate_evaluation_progress_to_diagnostic(&mut diagnostic, 0);
assert_eq!(diagnostic.candidate_count, Some(0));
assert_eq!(diagnostic.reason, "candidate_list_empty");
apply_ai_runtime_candidate_evaluation_progress_to_diagnostic(&mut diagnostic, 3);
assert_eq!(diagnostic.candidate_count, Some(3));
assert_eq!(diagnostic.reason, "candidate_evaluation_incomplete");
record_ai_runtime_candidate_skip_reason_on_diagnostic(
&mut diagnostic,
AUTH_API_KEY_CONCURRENCY_LIMIT_SKIP_REASON,
);
record_ai_runtime_candidate_skip_reason_on_diagnostic(
&mut diagnostic,
AUTH_API_KEY_CONCURRENCY_LIMIT_SKIP_REASON,
);
apply_ai_runtime_candidate_terminal_plan_reason_to_diagnostic(
&mut diagnostic,
"no_local_sync_plans",
);
assert_eq!(diagnostic.reason, "no_local_sync_plans");
diagnostic.candidate_count = Some(2);
apply_ai_runtime_candidate_terminal_plan_reason_to_diagnostic(
&mut diagnostic,
"no_local_sync_plans",
);
assert_eq!(
diagnostic.reason,
AUTH_API_KEY_CONCURRENCY_LIMIT_SKIP_REASON
);
record_ai_runtime_candidate_skip_reason_on_diagnostic(&mut diagnostic, "transport_missing");
apply_ai_runtime_candidate_terminal_plan_reason_to_diagnostic(
&mut diagnostic,
"no_local_sync_plans",
);
assert_eq!(diagnostic.reason, "all_candidates_skipped");
}
}
@@ -0,0 +1,273 @@
use aether_ai_formats::api::{
LocalGeminiFilesSpec, LocalOpenAiImageSpec, LocalOpenAiResponsesSpec,
LocalSameFormatProviderFamily, LocalSameFormatProviderSpec, LocalStandardSourceFamily,
LocalStandardSpec, LocalVideoCreateFamily, LocalVideoCreateSpec,
};
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AiRequestedModelFamily {
Standard,
Gemini,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AiExecutionSurfaceSpecMetadata {
pub api_format: &'static str,
pub decision_kind: &'static str,
pub report_kind: Option<&'static str>,
pub require_streaming: bool,
pub requested_model_family: Option<AiRequestedModelFamily>,
}
pub const fn ai_requested_model_family_for_standard_source(
family: LocalStandardSourceFamily,
) -> AiRequestedModelFamily {
match family {
LocalStandardSourceFamily::Standard => AiRequestedModelFamily::Standard,
LocalStandardSourceFamily::Gemini => AiRequestedModelFamily::Gemini,
}
}
pub const fn ai_standard_spec_metadata(spec: LocalStandardSpec) -> AiExecutionSurfaceSpecMetadata {
AiExecutionSurfaceSpecMetadata {
api_format: spec.api_format,
decision_kind: spec.decision_kind,
report_kind: Some(spec.report_kind),
require_streaming: spec.require_streaming,
requested_model_family: Some(ai_requested_model_family_for_standard_source(spec.family)),
}
}
pub const fn ai_same_format_provider_spec_metadata(
spec: LocalSameFormatProviderSpec,
) -> AiExecutionSurfaceSpecMetadata {
AiExecutionSurfaceSpecMetadata {
api_format: spec.api_format,
decision_kind: spec.decision_kind,
report_kind: Some(spec.report_kind),
require_streaming: spec.require_streaming,
requested_model_family: Some(ai_requested_model_family_for_same_format_provider(
spec.family,
)),
}
}
pub const fn ai_openai_responses_spec_metadata(
spec: LocalOpenAiResponsesSpec,
) -> AiExecutionSurfaceSpecMetadata {
AiExecutionSurfaceSpecMetadata {
api_format: spec.api_format,
decision_kind: spec.decision_kind,
report_kind: Some(spec.report_kind),
require_streaming: spec.require_streaming,
requested_model_family: None,
}
}
pub const fn ai_gemini_files_spec_metadata(
spec: LocalGeminiFilesSpec,
) -> AiExecutionSurfaceSpecMetadata {
AiExecutionSurfaceSpecMetadata {
api_format: "gemini:files",
decision_kind: spec.decision_kind,
report_kind: spec.report_kind,
require_streaming: spec.require_streaming,
requested_model_family: None,
}
}
pub const fn ai_openai_image_spec_metadata(
spec: LocalOpenAiImageSpec,
) -> AiExecutionSurfaceSpecMetadata {
AiExecutionSurfaceSpecMetadata {
api_format: spec.api_format,
decision_kind: spec.decision_kind,
report_kind: Some(spec.report_kind),
require_streaming: spec.require_streaming,
requested_model_family: Some(AiRequestedModelFamily::Standard),
}
}
pub const fn ai_video_create_spec_metadata(
spec: LocalVideoCreateSpec,
) -> AiExecutionSurfaceSpecMetadata {
AiExecutionSurfaceSpecMetadata {
api_format: spec.api_format,
decision_kind: spec.decision_kind,
report_kind: Some(spec.report_kind),
require_streaming: false,
requested_model_family: Some(ai_requested_model_family_for_video_create(spec.family)),
}
}
pub const fn ai_requested_model_family_for_same_format_provider(
family: LocalSameFormatProviderFamily,
) -> AiRequestedModelFamily {
match family {
LocalSameFormatProviderFamily::Standard => AiRequestedModelFamily::Standard,
LocalSameFormatProviderFamily::Gemini => AiRequestedModelFamily::Gemini,
}
}
pub const fn ai_requested_model_family_for_video_create(
family: LocalVideoCreateFamily,
) -> AiRequestedModelFamily {
match family {
LocalVideoCreateFamily::OpenAi => AiRequestedModelFamily::Standard,
LocalVideoCreateFamily::Gemini => AiRequestedModelFamily::Gemini,
}
}
pub fn extract_ai_gemini_model_from_path(path: &str) -> Option<String> {
let (_, suffix) = path.split_once("/models/")?;
let model = suffix
.split_once(':')
.map(|(value, _)| value)
.unwrap_or(suffix);
let model = model.trim();
if model.is_empty() {
None
} else {
Some(model.to_string())
}
}
pub fn extract_ai_standard_requested_model(body_json: &Value) -> Option<String> {
body_json
.get("model")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
pub fn extract_ai_gemini_body_requested_target(body_json: &Value) -> Option<String> {
extract_ai_standard_requested_model(body_json).or_else(|| {
body_json
.get("agent")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
}
pub fn extract_ai_requested_model_from_request_path(
request_path: &str,
body_json: &Value,
family: AiRequestedModelFamily,
) -> Option<String> {
match family {
AiRequestedModelFamily::Standard => extract_ai_standard_requested_model(body_json),
AiRequestedModelFamily::Gemini => extract_ai_gemini_model_from_path(request_path)
.or_else(|| extract_ai_gemini_body_requested_target(body_json)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use aether_ai_formats::api::{LocalStandardSourceMode, OPENAI_CHAT_SYNC_SUCCESS_REPORT_KIND};
#[test]
fn standard_spec_metadata_maps_model_family_and_report_kind() {
let metadata = ai_standard_spec_metadata(LocalStandardSpec {
mode: LocalStandardSourceMode::Chat,
family: LocalStandardSourceFamily::Standard,
api_format: "openai:chat",
decision_kind: "openai_chat_sync",
report_kind: OPENAI_CHAT_SYNC_SUCCESS_REPORT_KIND,
require_streaming: false,
});
assert_eq!(metadata.api_format, "openai:chat");
assert_eq!(
metadata.requested_model_family,
Some(AiRequestedModelFamily::Standard)
);
}
#[test]
fn video_spec_metadata_maps_gemini_family_without_stream_requirement() {
let metadata = ai_video_create_spec_metadata(LocalVideoCreateSpec {
family: LocalVideoCreateFamily::Gemini,
api_format: "gemini:video",
decision_kind: "gemini_video_create",
report_kind: "gemini_video_create_success",
});
assert!(!metadata.require_streaming);
assert_eq!(
metadata.requested_model_family,
Some(AiRequestedModelFamily::Gemini)
);
}
#[test]
fn gemini_model_path_parser_trims_method_suffix() {
let model = extract_ai_gemini_model_from_path(
"/v1beta/models/gemini-2.5-pro:streamGenerateContent",
);
assert_eq!(model.as_deref(), Some("gemini-2.5-pro"));
}
#[test]
fn standard_requested_model_parser_reads_request_body_model() {
let requested_model = extract_ai_standard_requested_model(
&serde_json::json!({ "model": " claude-sonnet-4 " }),
);
assert_eq!(requested_model.as_deref(), Some("claude-sonnet-4"));
}
#[test]
fn requested_model_parser_delegates_by_family() {
let body = serde_json::json!({ "model": " claude-sonnet-4 " });
assert_eq!(
extract_ai_requested_model_from_request_path(
"/v1/chat/completions",
&body,
AiRequestedModelFamily::Standard,
)
.as_deref(),
Some("claude-sonnet-4")
);
assert_eq!(
extract_ai_requested_model_from_request_path(
"/v1beta/models/gemini-2.5-pro:generateContent",
&serde_json::json!({}),
AiRequestedModelFamily::Gemini,
)
.as_deref(),
Some("gemini-2.5-pro")
);
}
#[test]
fn gemini_requested_model_parser_uses_body_model_when_path_has_no_model() {
let requested_model = extract_ai_requested_model_from_request_path(
"/v1internal:streamGenerateContent",
&serde_json::json!({ "model": " gemini-cli " }),
AiRequestedModelFamily::Gemini,
);
assert_eq!(requested_model.as_deref(), Some("gemini-cli"));
}
#[test]
fn gemini_requested_model_parser_uses_body_agent_when_path_has_no_model() {
let requested_model = extract_ai_requested_model_from_request_path(
"/v1/interactions",
&serde_json::json!({ "agent": " antigravity-preview-05-2026 " }),
AiRequestedModelFamily::Gemini,
);
assert_eq!(
requested_model.as_deref(),
Some("antigravity-preview-05-2026")
);
}
}